Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d55cde61d3 | |||
| 3ba19f28a2 | |||
| 1fcf9e11ec | |||
| db49904c6d | |||
| a011aebef5 | |||
| d1770c3476 | |||
| baa04d2d24 | |||
| ddb93c533c | |||
| 9afcbcd307 | |||
| e9b2eacf87 | |||
| d4467a44e2 | |||
| 68a863866a | |||
| cdbdc078d6 | |||
| 204577c7ce | |||
| dd73ae2469 | |||
| 6fbab53d56 | |||
| 4a3b1ae2e3 | |||
| c7b8007ce7 | |||
| cca5008805 | |||
| f68f6bc590 | |||
| 7ab97bb1a3 |
@@ -2517,6 +2517,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"ddc_power_off": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
|
||||||
|
},
|
||||||
"game_session": {
|
"game_session": {
|
||||||
"$ref": "#/components/schemas/GameSession",
|
"$ref": "#/components/schemas/GameSession",
|
||||||
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
|
||||||
@@ -2539,6 +2543,10 @@
|
|||||||
"mode_conflict": {
|
"mode_conflict": {
|
||||||
"$ref": "#/components/schemas/ModeConflict"
|
"$ref": "#/components/schemas/ModeConflict"
|
||||||
},
|
},
|
||||||
|
"pnp_disable_monitors": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off."
|
||||||
|
},
|
||||||
"preset": {
|
"preset": {
|
||||||
"$ref": "#/components/schemas/Preset"
|
"$ref": "#/components/schemas/Preset"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -355,10 +355,34 @@ fn decode_loop(
|
|||||||
};
|
};
|
||||||
let mut pcm = vec![0f32; pcm_scratch];
|
let mut pcm = vec![0f32; pcm_scratch];
|
||||||
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
let mut window_peak = 0f32; // loudest |sample| since the last log — tells a tone from silence
|
||||||
while !shutdown.load(Ordering::Relaxed) {
|
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||||
|
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||||
|
'pump: while !shutdown.load(Ordering::Relaxed) {
|
||||||
match client.next_audio(Duration::from_millis(5)) {
|
match client.next_audio(Duration::from_millis(5)) {
|
||||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
Ok(pkt) => {
|
||||||
|
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one that
|
||||||
|
// arrived: empty input synthesizes `frame_samples` of interpolation per missing
|
||||||
|
// packet — an inaudible fade instead of the click a hard gap makes in the ring.
|
||||||
|
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||||
|
let plc = frame_samples * channels;
|
||||||
|
if plc == 0 {
|
||||||
|
break; // no decoded frame yet to size the concealment from
|
||||||
|
}
|
||||||
|
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||||
|
let mut buf = free_rx
|
||||||
|
.try_recv()
|
||||||
|
.unwrap_or_else(|_| Vec::with_capacity(pcm_scratch));
|
||||||
|
buf.clear();
|
||||||
|
buf.extend_from_slice(&pcm[..samples * channels]);
|
||||||
|
match tx.try_send(buf) {
|
||||||
|
Ok(()) | Err(TrySendError::Full(_)) => {}
|
||||||
|
Err(TrySendError::Disconnected(_)) => break 'pump,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
|
frame_samples = samples;
|
||||||
let n = samples * channels;
|
let n = samples * channels;
|
||||||
for &s in &pcm[..n] {
|
for &s in &pcm[..n] {
|
||||||
window_peak = window_peak.max(s.abs());
|
window_peak = window_peak.max(s.abs());
|
||||||
@@ -393,7 +417,8 @@ fn decode_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => log::debug!("audio: opus decode: {e}"),
|
Err(e) => log::debug!("audio: opus decode: {e}"),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {} // timeout
|
Err(PunktfunkError::NoFrame) => {} // timeout
|
||||||
Err(_) => break, // session closed
|
Err(_) => break, // session closed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use punktfunk_core::error::PunktfunkError;
|
|||||||
use punktfunk_core::session::Frame;
|
use punktfunk_core::session::Frame;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -202,6 +202,8 @@ fn run_sync(
|
|||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
let mut rendered: u64 = 0;
|
let mut rendered: u64 = 0;
|
||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
|
let mut oversized_dropped: u64 = 0;
|
||||||
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
// The AU waiting for a free codec input buffer. `feed` is non-blocking; on transient input
|
||||||
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
// pressure the AU stays parked here instead of being dropped (a drop forces a keyframe
|
||||||
// round-trip) and we only pop the next one once it's queued.
|
// round-trip) and we only pop the next one once it's queued.
|
||||||
@@ -213,12 +215,12 @@ fn run_sync(
|
|||||||
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
// Skew-corrected latency stats (spec: design/stats-unification.md) use the negotiated
|
||||||
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
// host-minus-client clock offset (0 if the host didn't answer the skew handshake — then the
|
||||||
// HUD flags it "(same-host clock)").
|
// HUD flags it "(same-host clock)").
|
||||||
let clock_offset = client.clock_offset_ns;
|
let clock_offset = client.clock_offset_shared();
|
||||||
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
// Display stage (spec `display` + the capture→displayed headline): frames released with
|
||||||
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// reclaimed after the codec is dropped below.
|
// reclaimed after the codec is dropped below.
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
// HUD stage split: receipt timestamps keyed by the pts we queue into the codec, so the decoded
|
||||||
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
// point (output-buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back
|
||||||
@@ -256,6 +258,7 @@ fn run_sync(
|
|||||||
// the output buffer) for the decoded-point pairing in `drain`.
|
// the output buffer) for the decoded-point pairing in `drain`.
|
||||||
if stats.enabled() {
|
if stats.enabled() {
|
||||||
let received_ns = now_realtime_ns();
|
let received_ns = now_realtime_ns();
|
||||||
|
let clock_offset = clock_offset.load(Ordering::Relaxed);
|
||||||
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
let lat_ns = received_ns + clock_offset as i128 - frame.pts_ns as i128;
|
||||||
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
let lat_us = (lat_ns > 0 && lat_ns < 10_000_000_000)
|
||||||
.then_some((lat_ns / 1000) as u64);
|
.then_some((lat_ns / 1000) as u64);
|
||||||
@@ -295,7 +298,13 @@ fn run_sync(
|
|||||||
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
// and excluded, so ADPF sees this thread's real per-frame CPU cost, not the poll timeout.
|
||||||
let work_t0 = Instant::now();
|
let work_t0 = Instant::now();
|
||||||
if let Some(frame) = pending.take() {
|
if let Some(frame) = pending.take() {
|
||||||
if feed(&codec, &frame.data, frame.pts_ns / 1000) {
|
if feed(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
&frame.data,
|
||||||
|
frame.pts_ns / 1000,
|
||||||
|
&mut oversized_dropped,
|
||||||
|
) {
|
||||||
fed += 1;
|
fed += 1;
|
||||||
if fed % 300 == 0 {
|
if fed % 300 == 0 {
|
||||||
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
|
||||||
@@ -320,7 +329,7 @@ fn run_sync(
|
|||||||
wait,
|
wait,
|
||||||
&stats,
|
&stats,
|
||||||
&mut in_flight,
|
&mut in_flight,
|
||||||
clock_offset,
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
);
|
);
|
||||||
rendered += r;
|
rendered += r;
|
||||||
@@ -418,8 +427,10 @@ fn now_monotonic_ns() -> i128 {
|
|||||||
/// endpoint whenever the platform delivers render callbacks).
|
/// endpoint whenever the platform delivers render callbacks).
|
||||||
struct DisplayTracker {
|
struct DisplayTracker {
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
/// Host-minus-client clock offset (ns) for the skew-corrected end-to-end sample.
|
/// Live host-minus-client clock offset (ns) for the skew-corrected end-to-end sample —
|
||||||
clock_offset: i64,
|
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
|
||||||
|
/// keeps the leaked render-callback refcount from pinning the whole session alive.
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
|
||||||
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
|
||||||
/// callback early-outs) while the overlay is hidden.
|
/// callback early-outs) while the overlay is hidden.
|
||||||
@@ -427,7 +438,10 @@ struct DisplayTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DisplayTracker {
|
impl DisplayTracker {
|
||||||
fn new(stats: Arc<crate::stats::VideoStats>, clock_offset: i64) -> Arc<DisplayTracker> {
|
fn new(
|
||||||
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
|
) -> Arc<DisplayTracker> {
|
||||||
Arc::new(DisplayTracker {
|
Arc::new(DisplayTracker {
|
||||||
stats,
|
stats,
|
||||||
clock_offset,
|
clock_offset,
|
||||||
@@ -554,7 +568,8 @@ unsafe extern "C" fn on_frame_rendered(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let e2e_ns = displayed_ns + t.clock_offset as i128 - pts_us as i128 * 1000;
|
let e2e_ns =
|
||||||
|
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
|
||||||
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
|
||||||
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
|
||||||
t.stats.note_displayed(e2e_us, display_us);
|
t.stats.note_displayed(e2e_us, display_us);
|
||||||
@@ -827,13 +842,13 @@ fn run_async(
|
|||||||
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
|
||||||
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
|
||||||
// HUD is visible.
|
// HUD is visible.
|
||||||
let clock_offset = client.clock_offset_ns;
|
let clock_offset = client.clock_offset_shared();
|
||||||
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
|
||||||
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
// Display stage (spec `display` + the capture→displayed headline): the rendered frame is
|
||||||
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
// parked in the tracker at release; the OnFrameRendered callback pairs it with
|
||||||
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
|
||||||
// reclaimed after the codec is dropped below.
|
// reclaimed after the codec is dropped below.
|
||||||
let tracker = DisplayTracker::new(stats.clone(), clock_offset);
|
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
|
||||||
let render_cb = install_render_callback(&codec, &tracker);
|
let render_cb = install_render_callback(&codec, &tracker);
|
||||||
|
|
||||||
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
|
||||||
@@ -842,19 +857,13 @@ fn run_async(
|
|||||||
let client = client.clone();
|
let client = client.clone();
|
||||||
let stats = stats.clone();
|
let stats = stats.clone();
|
||||||
let in_flight = in_flight.clone();
|
let in_flight = in_flight.clone();
|
||||||
|
let clock_offset = clock_offset.clone();
|
||||||
let shutdown = shutdown.clone();
|
let shutdown = shutdown.clone();
|
||||||
let ev_tx = ev_tx.clone();
|
let ev_tx = ev_tx.clone();
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("pf-decode-feed".into())
|
.name("pf-decode-feed".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
feeder_loop(
|
feeder_loop(client, stats, in_flight, clock_offset, shutdown, ev_tx);
|
||||||
client,
|
|
||||||
stats,
|
|
||||||
in_flight,
|
|
||||||
clock_offset as i128,
|
|
||||||
shutdown,
|
|
||||||
ev_tx,
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
};
|
};
|
||||||
@@ -878,6 +887,8 @@ fn run_async(
|
|||||||
let mut fed: u64 = 0;
|
let mut fed: u64 = 0;
|
||||||
let mut rendered: u64 = 0;
|
let mut rendered: u64 = 0;
|
||||||
let mut discarded: u64 = 0;
|
let mut discarded: u64 = 0;
|
||||||
|
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
|
||||||
|
let mut oversized_dropped: u64 = 0;
|
||||||
let mut last_dropped = client.frames_dropped();
|
let mut last_dropped = client.frames_dropped();
|
||||||
let mut last_kf_req: Option<Instant> = None;
|
let mut last_kf_req: Option<Instant> = None;
|
||||||
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
|
||||||
@@ -922,14 +933,21 @@ fn run_async(
|
|||||||
if fmt_dirty {
|
if fmt_dirty {
|
||||||
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
|
||||||
}
|
}
|
||||||
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed);
|
feed_ready(
|
||||||
|
&codec,
|
||||||
|
&client,
|
||||||
|
&mut pending_aus,
|
||||||
|
&mut free_inputs,
|
||||||
|
&mut fed,
|
||||||
|
&mut oversized_dropped,
|
||||||
|
);
|
||||||
let had_output = !ready.is_empty();
|
let had_output = !ready.is_empty();
|
||||||
present_ready(
|
present_ready(
|
||||||
&codec,
|
&codec,
|
||||||
&mut ready,
|
&mut ready,
|
||||||
&stats,
|
&stats,
|
||||||
&in_flight,
|
&in_flight,
|
||||||
clock_offset,
|
clock_offset.load(Ordering::Relaxed),
|
||||||
&tracker,
|
&tracker,
|
||||||
&mut rendered,
|
&mut rendered,
|
||||||
&mut discarded,
|
&mut discarded,
|
||||||
@@ -999,7 +1017,7 @@ fn feeder_loop(
|
|||||||
client: Arc<NativeClient>,
|
client: Arc<NativeClient>,
|
||||||
stats: Arc<crate::stats::VideoStats>,
|
stats: Arc<crate::stats::VideoStats>,
|
||||||
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
|
||||||
clock_offset: i128,
|
clock_offset: Arc<AtomicI64>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
ev_tx: mpsc::Sender<DecodeEvent>,
|
ev_tx: mpsc::Sender<DecodeEvent>,
|
||||||
) {
|
) {
|
||||||
@@ -1010,6 +1028,7 @@ fn feeder_loop(
|
|||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
if stats.enabled() {
|
if stats.enabled() {
|
||||||
let received_ns = now_realtime_ns();
|
let received_ns = now_realtime_ns();
|
||||||
|
let clock_offset = clock_offset.load(Ordering::Relaxed) as i128;
|
||||||
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
|
||||||
let lat_us =
|
let lat_us =
|
||||||
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
|
||||||
@@ -1089,12 +1108,15 @@ fn dispatch_event(
|
|||||||
|
|
||||||
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
|
||||||
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
|
||||||
/// submitted; a too-large AU is truncated (logged) rather than dropped.
|
/// submitted; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
|
||||||
|
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||||
fn feed_ready(
|
fn feed_ready(
|
||||||
codec: &MediaCodec,
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
pending_aus: &mut VecDeque<Frame>,
|
pending_aus: &mut VecDeque<Frame>,
|
||||||
free_inputs: &mut VecDeque<usize>,
|
free_inputs: &mut VecDeque<usize>,
|
||||||
fed: &mut u64,
|
fed: &mut u64,
|
||||||
|
oversized_dropped: &mut u64,
|
||||||
) {
|
) {
|
||||||
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
while !pending_aus.is_empty() && !free_inputs.is_empty() {
|
||||||
let idx = free_inputs.pop_front().unwrap();
|
let idx = free_inputs.pop_front().unwrap();
|
||||||
@@ -1105,14 +1127,20 @@ fn feed_ready(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let au = &frame.data;
|
let au = &frame.data;
|
||||||
let n = au.len().min(dst.len());
|
if au.len() > dst.len() {
|
||||||
if n < au.len() {
|
// The slot was never queued, so it stays ours — recycle it for the next AU.
|
||||||
|
free_inputs.push_front(idx);
|
||||||
|
*oversized_dropped += 1;
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: AU {} > input buffer {}, truncated",
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
au.len(),
|
au.len(),
|
||||||
dst.len()
|
dst.len(),
|
||||||
|
*oversized_dropped
|
||||||
);
|
);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
let n = au.len();
|
||||||
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
|
||||||
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
|
||||||
unsafe {
|
unsafe {
|
||||||
@@ -1298,27 +1326,44 @@ fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bo
|
|||||||
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
|
||||||
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
|
||||||
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
|
||||||
/// parking it forever would wedge the loop on a broken codec).
|
/// parking it forever would wedge the loop on a broken codec). An AU larger than the input
|
||||||
fn feed(codec: &MediaCodec, au: &[u8], pts_us: u64) -> bool {
|
/// buffer is DROPPED (+ a recovery keyframe requested), never truncated — a truncated AU is
|
||||||
|
/// corrupt input the decoder chews on silently, poisoning the reference chain.
|
||||||
|
fn feed(
|
||||||
|
codec: &MediaCodec,
|
||||||
|
client: &NativeClient,
|
||||||
|
au: &[u8],
|
||||||
|
pts_us: u64,
|
||||||
|
oversized_dropped: &mut u64,
|
||||||
|
) -> bool {
|
||||||
match codec.dequeue_input_buffer(Duration::ZERO) {
|
match codec.dequeue_input_buffer(Duration::ZERO) {
|
||||||
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
Ok(DequeuedInputBufferResult::Buffer(mut buf)) => {
|
||||||
let n = {
|
let n = {
|
||||||
let dst = buf.buffer_mut();
|
let dst = buf.buffer_mut();
|
||||||
let n = au.len().min(dst.len());
|
if au.len() > dst.len() {
|
||||||
if n < au.len() {
|
*oversized_dropped += 1;
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"decode: AU {} > input buffer {}, truncated",
|
"decode: AU {} > input buffer {} — dropped ({} so far), requesting keyframe",
|
||||||
au.len(),
|
au.len(),
|
||||||
dst.len()
|
dst.len(),
|
||||||
|
*oversized_dropped
|
||||||
|
);
|
||||||
|
let _ = client.request_keyframe();
|
||||||
|
0 // return the slot with zero valid bytes — a no-op input, not corrupt data
|
||||||
|
} else {
|
||||||
|
let n = au.len();
|
||||||
|
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer),
|
||||||
|
// both valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so
|
||||||
|
// the cast write initializes exactly `dst[..n]`.
|
||||||
|
unsafe {
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
au.as_ptr(),
|
||||||
|
dst.as_mut_ptr().cast::<u8>(),
|
||||||
|
n,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// SAFETY: `au` and `dst` are distinct allocations (wire AU vs. codec buffer), both
|
|
||||||
// valid for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so the cast
|
|
||||||
// write initializes exactly `dst[..n]`.
|
|
||||||
unsafe {
|
|
||||||
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
|
|
||||||
}
|
|
||||||
n
|
n
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
if let Err(e) = codec.queue_input_buffer(buf, 0, n, pts_us, 0) {
|
||||||
log::warn!("decode: queue_input_buffer: {e}");
|
log::warn!("decode: queue_input_buffer: {e}");
|
||||||
|
|||||||
@@ -376,7 +376,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -412,7 +412,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SUPPORTED_PLATFORMS = macosx;
|
SUPPORTED_PLATFORMS = macosx;
|
||||||
@@ -449,7 +449,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -490,7 +490,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1;
|
MARKETING_VERSION = 0.9.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
PRODUCT_BUNDLE_IDENTIFIER = io.unom.punktfunk;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = appletvos;
|
SDKROOT = appletvos;
|
||||||
|
|||||||
@@ -68,25 +68,28 @@ struct ContentView: View {
|
|||||||
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
/// edge-to-edge (behind the notch); windowed respects the top inset so the title bar
|
||||||
/// never covers the video.
|
/// never covers the video.
|
||||||
@State private var isFullscreen = false
|
@State private var isFullscreen = false
|
||||||
|
#endif
|
||||||
|
#if os(macOS) || os(tvOS)
|
||||||
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
/// Shows the start-of-stream shortcut banner (the Windows client's discoverability
|
||||||
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
/// pattern): raised on every transition to `.streaming`, dropped by the banner's own
|
||||||
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
/// 6-second task. Independent of the stats HUD so the keys are discoverable even with
|
||||||
/// statistics off.
|
/// statistics off. On tvOS it carries the ONLY exits (hold Back / the pad chord) plus
|
||||||
|
/// the remote-as-pointer controls, so it must be seen at least once per session.
|
||||||
@State private var showShortcutHint = false
|
@State private var showShortcutHint = false
|
||||||
#endif
|
#endif
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
#endif
|
#endif
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
// A connected controller (+ the Settings toggle) swaps the whole home screen for
|
||||||
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
// GamepadHomeView instead of retrofitting HomeView's touch/desktop UI — see `home` below.
|
||||||
|
// On tvOS the same screens are focus-engine-driven, so the Siri Remote keeps working;
|
||||||
|
// with no (extended) controller attached tvOS falls back to HomeView as before.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
GamepadUIEnvironment.isActive(
|
GamepadUIEnvironment.isActive(
|
||||||
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
gamepadConnected: gamepadManager.active != nil, enabledSetting: gamepadUIEnabled)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -108,7 +111,7 @@ struct ContentView: View {
|
|||||||
.onChange(of: model.phase) { _, phase in
|
.onChange(of: model.phase) { _, phase in
|
||||||
switch phase {
|
switch phase {
|
||||||
case .streaming:
|
case .streaming:
|
||||||
#if os(macOS)
|
#if os(macOS) || os(tvOS)
|
||||||
showShortcutHint = true // the 6 s shortcut banner, per session start
|
showShortcutHint = true // the 6 s shortcut banner, per session start
|
||||||
#endif
|
#endif
|
||||||
// A session actually started — remember it on the card ("Connected … ago"
|
// A session actually started — remember it on the card ("Connected … ago"
|
||||||
@@ -278,13 +281,30 @@ struct ContentView: View {
|
|||||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#elseif os(iOS)
|
#else
|
||||||
Group {
|
Group {
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
GamepadHomeView(
|
GamepadHomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
libraryTarget: $libraryTarget, waker: waker,
|
libraryTarget: $libraryTarget, waker: waker,
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
connect: { connect($0) }, connectDiscovered: connectDiscovered)
|
||||||
|
// On tvOS pairing/library normally present from HomeView's navigationDestinations
|
||||||
|
// — which aren't mounted while the gamepad launcher is up. Give the launcher its
|
||||||
|
// own presenters (exactly one of the two homes is mounted at a time, so these can
|
||||||
|
// never double-present against HomeView's routes). Menu closes a cover the same
|
||||||
|
// way B backs out elsewhere; PairSheet's own onDisappear cancels a live ceremony.
|
||||||
|
#if os(tvOS)
|
||||||
|
.fullScreenCover(item: $pairingTarget) { host in
|
||||||
|
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||||
|
.onExitCommand { pairingTarget = nil }
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $libraryTarget) { host in
|
||||||
|
NavigationStack {
|
||||||
|
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
||||||
|
}
|
||||||
|
.onExitCommand { libraryTarget = nil }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
} else {
|
} else {
|
||||||
HomeView(
|
HomeView(
|
||||||
store: store, model: model, discovery: discovery,
|
store: store, model: model, discovery: discovery,
|
||||||
@@ -295,14 +315,6 @@ struct ContentView: View {
|
|||||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
HomeView(
|
|
||||||
store: store, model: model, discovery: discovery,
|
|
||||||
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
|
|
||||||
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
|
|
||||||
showSettings: $showSettings,
|
|
||||||
connect: { connect($0) }, connectDiscovered: connectDiscovered,
|
|
||||||
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,11 +374,14 @@ struct ContentView: View {
|
|||||||
#else
|
#else
|
||||||
.background(Color.black)
|
.background(Color.black)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
// Siri Remote MENU = disconnect (the idiomatic tvOS "back"). With no focusable
|
// SWALLOW Menu/B during a session — a game controller's B button ALSO surfaces as this
|
||||||
// disconnect control during play, the controller's buttons flow to the host instead of
|
// UIKit menu press, so the old instant-disconnect here ended the session on every B
|
||||||
// driving the focus engine. NOTE: a game controller's Menu is also forwarded to the
|
// press in gameplay. The button still reaches the host via GamepadCapture; the
|
||||||
// host as Start — the Siri Remote is the intended disconnect path.
|
// DELIBERATE exits are holding the remote's Back ≥ 1 s (SiriRemotePointer) and holding
|
||||||
.onExitCommand { model.disconnect() }
|
// L1+R1+Start+Select ≥ 1.5 s on a pad (GamepadCapture's escape chord), both surfaced by
|
||||||
|
// the start-of-stream banner. The empty handler is what keeps the press from bubbling
|
||||||
|
// out and suspending the app.
|
||||||
|
.onExitCommand {}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,17 +433,18 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||||
}
|
}
|
||||||
#if os(macOS)
|
#if os(macOS) || os(tvOS)
|
||||||
// The start-of-stream shortcut banner (Windows-client parity): the full
|
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||||
// reserved key set on a glass pill, bottom-centre, for the first 6 seconds of
|
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
|
||||||
// every session — independent of the stats HUD, so the keys are discoverable
|
// every session — independent of the stats HUD, so the keys are discoverable
|
||||||
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||||
// if the session view goes away first).
|
// if the session view goes away first). On tvOS it carries the ONLY exits —
|
||||||
|
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
|
||||||
|
// session branch), so the hold gestures must be told to the user.
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if captureEnabled && showShortcutHint {
|
if captureEnabled && showShortcutHint {
|
||||||
Text("Click the stream to capture · ⌃⌥⇧Q releases the mouse · "
|
Text(Self.shortcutHintText)
|
||||||
+ "⌃⌥⇧D disconnects · ⌃⌥⇧S stats")
|
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -472,6 +488,17 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
private static let shortcutHintText =
|
||||||
|
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||||
|
private static let shortcutHintFont: CGFloat = 12
|
||||||
|
#elseif os(tvOS)
|
||||||
|
private static let shortcutHintText =
|
||||||
|
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||||
|
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||||
|
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||||
|
#endif
|
||||||
|
|
||||||
// MARK: - Connect
|
// MARK: - Connect
|
||||||
|
|
||||||
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
private func connect(_ host: StoredHost, launchID: String? = nil, allowTofu: Bool? = nil) {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS) — the controller counterpart of
|
// The gamepad-driven "Add Host" screen (iOS/iPadOS/macOS/tvOS) — the controller counterpart of
|
||||||
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
// AddHostSheet, reached from the launcher's Add Host tile. Three field rows (name / address /
|
||||||
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
// port) plus the Add action, navigated with the same vertical focus list as the gamepad settings;
|
||||||
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
// A on a field opens GamepadKeyboard in a bottom tray, so a host can be registered end to end
|
||||||
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
// without touching the screen. Field edits are live (the row shows every keystroke); B closes the
|
||||||
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
// keyboard first, then cancels the screen — the same "back peels one layer" rule as a console UI.
|
||||||
|
// tvOS swaps the custom keyboard tray for the SYSTEM fullscreen keyboard (TVTextEntry): unlike
|
||||||
|
// iOS/macOS, tvOS HAS a first-class controller/remote-drivable text entry, so the native one wins.
|
||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
|
|
||||||
struct GamepadAddHostView: View {
|
struct GamepadAddHostView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@@ -37,22 +39,22 @@ struct GamepadAddHostView: View {
|
|||||||
isActive: editing == nil
|
isActive: editing == nil
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: 620)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("Add Host")
|
Text("Add Host")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
if !compact {
|
if !compact {
|
||||||
Text("Hosts on this network appear automatically — add one by address "
|
Text("Hosts on this network appear automatically — add one by address "
|
||||||
+ "for everything else.")
|
+ "for everything else.")
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: 440)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth * 0.72)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
@@ -75,10 +77,38 @@ struct GamepadAddHostView: View {
|
|||||||
.onChange(of: port) { _, value in
|
.onChange(of: port) { _, value in
|
||||||
if value.count > 5 { port = String(value.prefix(5)) }
|
if value.count > 5 { port = String(value.prefix(5)) }
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// tvOS types with the SYSTEM fullscreen keyboard (TVTextEntry) instead of the custom
|
||||||
|
// tray — the remote and the pad both drive it natively. Same `editing` state as the
|
||||||
|
// tray, just a different presentation; done (or Menu, edits-stick) commits and returns.
|
||||||
|
.fullScreenCover(isPresented: Binding(
|
||||||
|
get: { editing != nil },
|
||||||
|
set: { if !$0 { editing = nil } })
|
||||||
|
) {
|
||||||
|
if let field = editing {
|
||||||
|
TVTextEntry(
|
||||||
|
title: fieldTitle(field),
|
||||||
|
text: editingBinding(field).wrappedValue,
|
||||||
|
keyboardType: keyboardType(field)
|
||||||
|
) { value in
|
||||||
|
commitEntry(field, value)
|
||||||
|
editing = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard tray while editing, the controls legend otherwise.
|
/// The keyboard tray while editing, the controls legend otherwise. (tvOS never shows the
|
||||||
|
/// tray — `editing` presents the system keyboard cover instead — so it's legend-only there.)
|
||||||
@ViewBuilder private var bottomTray: some View {
|
@ViewBuilder private var bottomTray: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
GamepadHintBar(hints: [
|
||||||
|
.init(glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), text: "Select"),
|
||||||
|
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Cancel"),
|
||||||
|
])
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
#else
|
||||||
if let editing {
|
if let editing {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
GamepadKeyboard(
|
GamepadKeyboard(
|
||||||
@@ -104,6 +134,7 @@ struct GamepadAddHostView: View {
|
|||||||
])
|
])
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
/// Touch/click fallback for closing — the controller path is B, a hardware keyboard's Esc
|
||||||
@@ -111,14 +142,16 @@ struct GamepadAddHostView: View {
|
|||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.keyboardShortcut(.cancelAction)
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||||
|
#endif
|
||||||
.accessibilityLabel("Cancel")
|
.accessibilityLabel("Cancel")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,19 +175,20 @@ struct GamepadAddHostView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
HStack(spacing: 14) {
|
let m = GamepadFormMetrics.self
|
||||||
|
return HStack(spacing: 14) {
|
||||||
if row.isAction {
|
if row.isAction {
|
||||||
Label("Add Host", systemImage: "plus.circle.fill")
|
Label("Add Host", systemImage: "plus.circle.fill")
|
||||||
.font(.geist(16, .semibold, relativeTo: .body))
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
.foregroundStyle(canAdd ? Color.brand : .white.opacity(0.35))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
} else {
|
} else {
|
||||||
Text(row.label)
|
Text(row.label)
|
||||||
.font(.geist(16, .semibold, relativeTo: .body))
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
Text(row.value.isEmpty ? row.placeholder : row.value)
|
Text(row.value.isEmpty ? row.placeholder : row.value)
|
||||||
.font(.geistFixed(15, .medium))
|
.font(.geistFixed(m.valueFont, .medium))
|
||||||
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
.foregroundStyle(row.value.isEmpty ? .white.opacity(0.35) : .white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.head) // keep the end of a long address visible while typing
|
.truncationMode(.head) // keep the end of a long address visible while typing
|
||||||
@@ -162,20 +196,20 @@ struct GamepadAddHostView: View {
|
|||||||
// The live-edit caret: this row is what the keyboard tray is typing into.
|
// The live-edit caret: this row is what the keyboard tray is typing into.
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color.brand)
|
.fill(Color.brand)
|
||||||
.frame(width: 2, height: 18)
|
.frame(width: 2, height: m.labelFont + 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, m.rowHPad)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, m.rowVPad)
|
||||||
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
|
||||||
// takes the brand wash, and the edited row keeps its brand caret border.
|
// takes the brand wash, and the edited row keeps its brand caret border.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
|
||||||
interactive: focused)
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
|
||||||
lineWidth: 1)
|
lineWidth: 1)
|
||||||
@@ -235,5 +269,41 @@ struct GamepadAddHostView: View {
|
|||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// MARK: - System keyboard plumbing (see the fullScreenCover on `body`)
|
||||||
|
|
||||||
|
private func fieldTitle(_ id: String) -> String {
|
||||||
|
switch id {
|
||||||
|
case "name": return "Name (optional)"
|
||||||
|
case "port": return "Port"
|
||||||
|
default: return "Address (IP or hostname)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// .URL for the address (dots on the primary layer, no autocapitalize) — the closest tvOS
|
||||||
|
/// keyboard to "hostname or IP".
|
||||||
|
private func keyboardType(_ id: String) -> UIKeyboardType {
|
||||||
|
switch id {
|
||||||
|
case "port": return .numberPad
|
||||||
|
case "address": return .URL
|
||||||
|
default: return .default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a system-keyboard result, enforcing what `allowedCharacters` enforces per keystroke
|
||||||
|
/// on the other platforms (the system keyboard will type anything).
|
||||||
|
private func commitEntry(_ id: String, _ value: String) {
|
||||||
|
switch id {
|
||||||
|
case "port":
|
||||||
|
editingBinding(id).wrappedValue = String(value.filter(\.isNumber).prefix(5))
|
||||||
|
case "address":
|
||||||
|
editingBinding(id).wrappedValue = value
|
||||||
|
.replacingOccurrences(of: " ", with: "")
|
||||||
|
default:
|
||||||
|
editingBinding(id).wrappedValue = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
// The one piece of gamepad-menu machinery shared by the host launcher (GamepadHomeView) and the
|
||||||
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
// library coverflow (LibraryCoverflowView): a horizontal, center-snapping carousel driven entirely
|
||||||
// by a controller (iOS/iPadOS/macOS).
|
// by a controller (iOS/iPadOS/macOS) — or, on tvOS, by the NATIVE FOCUS ENGINE: every card is a
|
||||||
|
// focusable Button, so the Siri Remote and a game controller both navigate through the system
|
||||||
|
// (dpad/swipe moves focus, select activates, Menu backs out at the presentation level), and the
|
||||||
|
// cursor/scroll chase the focused card instead of the poll. The poll still runs on tvOS but
|
||||||
|
// carries ONLY the Y/X actions (library/settings) — buttons the focus engine has no concept of.
|
||||||
|
// The iOS/macOS poll-driven behavior is untouched by the tvOS mode.
|
||||||
//
|
//
|
||||||
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
// The scrolling is pure native SwiftUI — `.scrollTargetLayout()` + `.scrollTargetBehavior(.viewAligned)`
|
||||||
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
// snap exactly one item to center, and symmetric `.safeAreaPadding(.horizontal)` (sized off the live
|
||||||
@@ -24,7 +29,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
|
|
||||||
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hashable {
|
||||||
let items: [Item]
|
let items: [Item]
|
||||||
@@ -54,6 +59,11 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
|
|
||||||
@State private var input = GamepadMenuInput(manager: .shared)
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
@State private var haptics = MenuHaptics(manager: .shared)
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
#if os(tvOS)
|
||||||
|
/// tvOS: the focus engine is the navigation authority — `cursor`/`scrolledID` chase this,
|
||||||
|
/// never the other way around (mirroring the poll's cursor-first discipline).
|
||||||
|
@FocusState private var focusedID: Item.ID?
|
||||||
|
#endif
|
||||||
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
/// Authoritative gamepad cursor (index into `items`). Never assigned from scroll read-back
|
||||||
/// while the gamepad is driving — that's the whole desync fix.
|
/// while the gamepad is driving — that's the whole desync fix.
|
||||||
@State private var cursor = 0
|
@State private var cursor = 0
|
||||||
@@ -81,26 +91,72 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
let inset = max(0, (geo.size.width - itemWidth) / 2)
|
||||||
|
ScrollViewReader { proxy in
|
||||||
ScrollView(.horizontal) {
|
ScrollView(.horizontal) {
|
||||||
HStack(spacing: spacing) {
|
HStack(spacing: spacing) {
|
||||||
ForEach(items) { item in
|
ForEach(items) { item in
|
||||||
|
#if os(tvOS)
|
||||||
|
// A focusable Button per card: the focus engine does the navigating
|
||||||
|
// (remote swipes and pad dpad alike), select activates. The bare style
|
||||||
|
// below keeps the tile's own look — the `.scrollTransition` center pop
|
||||||
|
// is the focus treatment, since focus and center track each other.
|
||||||
|
Button { activate(item) } label: {
|
||||||
|
card(item)
|
||||||
|
.frame(width: itemWidth)
|
||||||
|
}
|
||||||
|
.buttonStyle(ConsoleBareButtonStyle())
|
||||||
|
.focused($focusedID, equals: item.id)
|
||||||
|
.id(item.id)
|
||||||
|
#else
|
||||||
card(item)
|
card(item)
|
||||||
.frame(width: itemWidth)
|
.frame(width: itemWidth)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { tap(item) }
|
.onTapGesture { tap(item) }
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
.frame(height: geo.size.height) // fill so shorter cards center vertically
|
||||||
.scrollTargetLayout()
|
.scrollTargetLayout()
|
||||||
}
|
}
|
||||||
|
// The two-way `.scrollPosition` + snap machinery serves the POLL/touch platforms.
|
||||||
|
// Not on tvOS: that binding DROPS a write landing mid-animation (the very desync
|
||||||
|
// the poll's cursor design exists to avoid — see the header), and on tvOS the
|
||||||
|
// focus engine's own reveal-scrolls are always in flight, so drops were routine
|
||||||
|
// ("navigation not reflected in the scroll view"). tvOS scrolls imperatively
|
||||||
|
// below instead — scrollTo RE-TARGETS mid-animation (the GamepadMenuList pattern).
|
||||||
|
#if !os(tvOS)
|
||||||
.scrollPosition(id: $scrolledID)
|
.scrollPosition(id: $scrolledID)
|
||||||
.scrollTargetBehavior(.viewAligned)
|
.scrollTargetBehavior(.viewAligned)
|
||||||
|
#endif
|
||||||
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
// .never, not .hidden — macOS's "always show scroll bars" setting overrides .hidden
|
||||||
// and paints a scroller across the console strip.
|
// and paints a scroller across the console strip.
|
||||||
.scrollIndicators(.never)
|
.scrollIndicators(.never)
|
||||||
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
.scrollClipDisabled() // let the focused card scale up past the strip bounds
|
||||||
.safeAreaPadding(.horizontal, inset)
|
.safeAreaPadding(.horizontal, inset)
|
||||||
.offset(x: bumpOffset)
|
.offset(x: bumpOffset)
|
||||||
|
#if os(tvOS)
|
||||||
|
// Land initial focus on the first card (the launcher's first host / the coverflow's
|
||||||
|
// first title) instead of wherever the engine guesses.
|
||||||
|
.defaultFocus($focusedID, items.first?.id)
|
||||||
|
// Focus moved (remote swipe / pad dpad) — chase it: cursor, detail selection,
|
||||||
|
// controller detent, and an imperative center scroll.
|
||||||
|
.onChange(of: focusedID) { _, newValue in
|
||||||
|
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||||
|
cursor = idx
|
||||||
|
lastNav = Date()
|
||||||
|
haptics.move()
|
||||||
|
selection = newValue
|
||||||
|
withAnimation(.easeOut(duration: scrollAnim)) {
|
||||||
|
proxy.scrollTo(newValue, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The list changed under a stable focus (discovered hosts prepend tiles): the
|
||||||
|
// content shifted but no focus change fires above — re-center the focused card.
|
||||||
|
.onChange(of: items.map(\.id)) { _, _ in
|
||||||
|
if let id = focusedID { proxy.scrollTo(id, anchor: .center) }
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sensoryFeedback(.selection, trigger: cursor)
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
@@ -128,13 +184,16 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
// A touch drag settles the scroll onto a new id: adopt it as the cursor. Ignored while a
|
||||||
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
// programmatic scroll is animating (its own intermediate id write-backs would regress the
|
||||||
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
// cursor) and briefly after a gamepad move (the same reason), so only a genuine touch drag
|
||||||
// — which never sets `isScrolling` — moves the cursor here.
|
// — which never sets `isScrolling` — moves the cursor here. Not on tvOS: there is no touch
|
||||||
|
// drag, and the focus engine's own reveal-scrolls must never steal the cursor from focus.
|
||||||
|
#if !os(tvOS)
|
||||||
.onChange(of: scrolledID) { _, newValue in
|
.onChange(of: scrolledID) { _, newValue in
|
||||||
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
guard !isScrolling, Date().timeIntervalSince(lastNav) > navSettle else { return }
|
||||||
guard let idx = index(of: newValue), idx != cursor else { return }
|
guard let idx = index(of: newValue), idx != cursor else { return }
|
||||||
cursor = idx
|
cursor = idx
|
||||||
selection = newValue
|
selection = newValue
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
// Re-seed a dropped/changed selection AND re-wire the input callbacks so they capture the
|
||||||
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
// current `items` value (a plain array — unlike an observed object it would otherwise go
|
||||||
// stale in the closures stored on `input`).
|
// stale in the closures stored on `input`).
|
||||||
@@ -147,12 +206,20 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
// MARK: - Input wiring
|
// MARK: - Input wiring
|
||||||
|
|
||||||
private func wire() {
|
private func wire() {
|
||||||
|
#if os(tvOS)
|
||||||
|
// The focus engine owns move/confirm/back on tvOS (that's what keeps the Siri Remote
|
||||||
|
// working on this screen — and what routes Menu through the system's back semantics).
|
||||||
|
// The poll carries only the buttons focus has no concept of: Y/X, the screen actions.
|
||||||
|
input.onSecondary = onSecondary
|
||||||
|
input.onTertiary = onTertiary
|
||||||
|
#else
|
||||||
input.onMove = { move($0) }
|
input.onMove = { move($0) }
|
||||||
input.onConfirm = { activate() }
|
input.onConfirm = { activate() }
|
||||||
input.onSecondary = onSecondary
|
input.onSecondary = onSecondary
|
||||||
input.onTertiary = onTertiary
|
input.onTertiary = onTertiary
|
||||||
input.onBack = onBack
|
input.onBack = onBack
|
||||||
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
input.onShoulder = shoulderJump > 0 ? { shoulder(right: $0) } : nil
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func move(_ direction: GamepadMenuInput.Direction) {
|
private func move(_ direction: GamepadMenuInput.Direction) {
|
||||||
@@ -212,9 +279,14 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
|
|
||||||
private func activate() {
|
private func activate() {
|
||||||
guard cursor >= 0, cursor < items.count else { return }
|
guard cursor >= 0, cursor < items.count else { return }
|
||||||
|
activate(items[cursor])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared confirm tail — the poll activates the cursor's item, a tvOS Button its own.
|
||||||
|
private func activate(_ item: Item) {
|
||||||
activateTick &+= 1
|
activateTick &+= 1
|
||||||
haptics.confirm()
|
haptics.confirm()
|
||||||
onActivate(items[cursor])
|
onActivate(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
/// Touch fallback matching the rest of the app: tapping the centered card activates it, tapping
|
||||||
@@ -257,6 +329,13 @@ struct GamepadCarousel<Item: Identifiable, Card: View>: View where Item.ID: Hash
|
|||||||
scrolledID = id
|
scrolledID = id
|
||||||
selection = id
|
selection = id
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// Keep real focus on the reconciled item when its old target vanished from the list —
|
||||||
|
// the engine would otherwise pick a neighbour by geometry and drag the cursor with it.
|
||||||
|
if focusedID == nil || index(of: focusedID) == nil, cursor < items.count {
|
||||||
|
focusedID = items[cursor].id
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func boundaryBump(forward: Bool) {
|
private func boundaryBump(forward: Bool) {
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
// GamepadAddHostView, LibraryCoverflowView): the full-bleed console backdrop, the
|
||||||
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
// controller-glyph hint bar, and the connected-controller status chip. One look across every
|
||||||
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
// screen is what makes the gamepad UI read as a coherent mode rather than a set of themed pages.
|
||||||
// iOS/iPadOS and macOS (the couch Mac-mini case); tvOS keeps its native focus engine instead.
|
// iOS/iPadOS, macOS (the couch Mac-mini case), and tvOS — where the same screens are driven by
|
||||||
|
// the native focus engine instead of the controller poll (see GamepadCarousel/GamepadMenuList).
|
||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
/// The active controller's real glyph for a button (Xbox "A", DualSense ✕, …) via
|
||||||
@@ -31,6 +32,51 @@ func gamepadTitleTopPadding(compact: Bool) -> CGFloat {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point size for a gamepad screen's pinned title: TV-large on tvOS (read from the couch), the
|
||||||
|
/// in-hand compact-aware sizes elsewhere.
|
||||||
|
func gamepadTitleSize(compact: Bool) -> CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
44
|
||||||
|
#else
|
||||||
|
compact ? 20 : 30
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metrics shared by the gamepad form screens' glass rows (GamepadSettingsView,
|
||||||
|
/// GamepadAddHostView) — one set of numbers so the two screens read as the same surface,
|
||||||
|
/// sized for the couch on tvOS and for the hand elsewhere.
|
||||||
|
enum GamepadFormMetrics {
|
||||||
|
#if os(tvOS)
|
||||||
|
static let headerFont: CGFloat = 17
|
||||||
|
static let labelFont: CGFloat = 23
|
||||||
|
static let valueFont: CGFloat = 21
|
||||||
|
static let iconFont: CGFloat = 24
|
||||||
|
static let iconWidth: CGFloat = 40
|
||||||
|
static let chevronFont: CGFloat = 16
|
||||||
|
static let rowHPad: CGFloat = 24
|
||||||
|
static let rowVPad: CGFloat = 19
|
||||||
|
static let rowCorner: CGFloat = 18
|
||||||
|
static let rowMaxWidth: CGFloat = 920
|
||||||
|
static let detailFont: CGFloat = 19
|
||||||
|
static let closeFont: CGFloat = 20
|
||||||
|
static let closeSide: CGFloat = 48
|
||||||
|
#else
|
||||||
|
static let headerFont: CGFloat = 12
|
||||||
|
static let labelFont: CGFloat = 16
|
||||||
|
static let valueFont: CGFloat = 15
|
||||||
|
static let iconFont: CGFloat = 17
|
||||||
|
static let iconWidth: CGFloat = 28
|
||||||
|
static let chevronFont: CGFloat = 12
|
||||||
|
static let rowHPad: CGFloat = 16
|
||||||
|
static let rowVPad: CGFloat = 13
|
||||||
|
static let rowCorner: CGFloat = 14
|
||||||
|
static let rowMaxWidth: CGFloat = 620
|
||||||
|
static let detailFont: CGFloat = 13
|
||||||
|
static let closeFont: CGFloat = 14
|
||||||
|
static let closeSide: CGFloat = 34
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/// One glyph + label cell in a hint bar.
|
/// One glyph + label cell in a hint bar.
|
||||||
struct GamepadHint: Identifiable {
|
struct GamepadHint: Identifiable {
|
||||||
let glyph: String
|
let glyph: String
|
||||||
@@ -45,21 +91,32 @@ struct GamepadHint: Identifiable {
|
|||||||
struct GamepadHintBar: View {
|
struct GamepadHintBar: View {
|
||||||
let hints: [GamepadHint]
|
let hints: [GamepadHint]
|
||||||
|
|
||||||
|
// 10-foot legend on tvOS, in-hand sizes elsewhere.
|
||||||
|
#if os(tvOS)
|
||||||
|
private static let glyphFont: CGFloat = 27
|
||||||
|
private static let textFont: CGFloat = 20
|
||||||
|
private static let pad: CGFloat = 18
|
||||||
|
#else
|
||||||
|
private static let glyphFont: CGFloat = 19
|
||||||
|
private static let textFont: CGFloat = 14
|
||||||
|
private static let pad: CGFloat = 13
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
ForEach(hints) { hint in
|
ForEach(hints) { hint in
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
Image(systemName: hint.glyph)
|
Image(systemName: hint.glyph)
|
||||||
.font(.system(size: 19))
|
.font(.system(size: Self.glyphFont))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
Text(hint.text)
|
Text(hint.text)
|
||||||
}
|
}
|
||||||
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
.fixedSize() // keep glyph + label together; never truncate a hint mid-word
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.geist(14, .semibold, relativeTo: .subheadline))
|
.font(.geist(Self.textFont, .semibold, relativeTo: .subheadline))
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
.padding(13)
|
.padding(Self.pad)
|
||||||
.consoleGlass(Capsule())
|
.consoleGlass(Capsule())
|
||||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
}
|
}
|
||||||
@@ -297,30 +354,56 @@ struct GamepadFormBackground: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
/// Bare chrome for the focusable console Buttons (carousel cards, menu-list rows) on tvOS: the
|
||||||
|
/// tile/row draws its own look and the screen's own focus treatment marks the focused element
|
||||||
|
/// (the carousel's `.scrollTransition` center pop, the list row's `focused` styling), so the
|
||||||
|
/// system's lift/halo would double up on it. Press feedback is a small dip, matching the
|
||||||
|
/// interactive-glass feel elsewhere.
|
||||||
|
struct ConsoleBareButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
||||||
|
.animation(.smooth(duration: 0.15), value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
/// "Which pad is driving this UI" — the active controller's name and battery, worn as a quiet
|
||||||
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
|
||||||
/// when the pad or its battery state changes.
|
/// when the pad or its battery state changes.
|
||||||
struct ControllerStatusChip: View {
|
struct ControllerStatusChip: View {
|
||||||
let controller: GamepadManager.DiscoveredController
|
let controller: GamepadManager.DiscoveredController
|
||||||
|
|
||||||
|
// Legible from the couch on tvOS, quiet in hand elsewhere.
|
||||||
|
#if os(tvOS)
|
||||||
|
private static let font: CGFloat = 17
|
||||||
|
private static let hPad: CGFloat = 16
|
||||||
|
private static let vPad: CGFloat = 10
|
||||||
|
#else
|
||||||
|
private static let font: CGFloat = 12
|
||||||
|
private static let hPad: CGFloat = 12
|
||||||
|
private static let vPad: CGFloat = 7
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
Image(systemName: controller.hasTouchpadAndMotion
|
Image(systemName: controller.hasTouchpadAndMotion
|
||||||
? "playstation.logo" : "gamecontroller.fill")
|
? "playstation.logo" : "gamecontroller.fill")
|
||||||
.font(.system(size: 12))
|
.font(.system(size: Self.font))
|
||||||
Text(controller.name)
|
Text(controller.name)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
if let level = controller.batteryLevel {
|
if let level = controller.batteryLevel {
|
||||||
Image(systemName: batterySymbol(level))
|
Image(systemName: batterySymbol(level))
|
||||||
.font(.system(size: 12))
|
.font(.system(size: Self.font))
|
||||||
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
.foregroundStyle(level <= 0.2 && !controller.isCharging
|
||||||
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
? AnyShapeStyle(.red) : AnyShapeStyle(.white.opacity(0.7)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.geist(12, .medium, relativeTo: .caption))
|
.font(.geist(Self.font, .medium, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, Self.hPad)
|
||||||
.padding(.vertical, 7)
|
.padding(.vertical, Self.vPad)
|
||||||
.background(Capsule().fill(.white.opacity(0.08)))
|
.background(Capsule().fill(.white.opacity(0.08)))
|
||||||
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// The gamepad-driven home screen (iOS/iPadOS only): a distinct, "10-foot" console-style host
|
// The gamepad-driven home screen: a distinct, "10-foot" console-style host
|
||||||
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
|
||||||
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
|
||||||
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
|
||||||
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
|
||||||
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
|
||||||
// (macOS has no fullScreenCover). tvOS never mounts this view (native focus engine instead).
|
// (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
|
||||||
|
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
|
||||||
|
// mirrors X for Settings since the focus engine has no concept of that button.
|
||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
/// One navigable tile: a saved host, a discovered-but-unsaved one, or the trailing Add Host
|
||||||
@@ -60,8 +62,9 @@ struct GamepadHomeView: View {
|
|||||||
let connect: (StoredHost) -> Void
|
let connect: (StoredHost) -> Void
|
||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
|
|
||||||
/// Same experimental gate the touch grid's "Browse Library…" context-menu item uses.
|
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
/// Settings "Game library" toggle opts out).
|
||||||
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@Environment(\.verticalSizeClass) private var vSizeClass
|
||||||
@@ -104,6 +107,12 @@ struct GamepadHomeView: View {
|
|||||||
try? await Task.sleep(for: .seconds(10))
|
try? await Task.sleep(for: .seconds(10))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
|
||||||
|
// X, and historically tvOS maps a pad's X to this same press — the poll and this command
|
||||||
|
// double-firing just sets the same Bool twice.
|
||||||
|
#if os(tvOS)
|
||||||
|
.onPlayPauseCommand { showSettings = true }
|
||||||
|
#endif
|
||||||
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
// The settings / add-host screens take over the controller (the carousel's `isActive`
|
||||||
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
// gate above). iOS presents them full screen — the immersive console feel; macOS has no
|
||||||
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
// fullScreenCover, so they become generously sized sheets over the dimmed launcher.
|
||||||
@@ -128,11 +137,17 @@ struct GamepadHomeView: View {
|
|||||||
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
|
||||||
|
|
||||||
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
@ViewBuilder private func hero(for size: CGSize) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
|
||||||
|
let cardWidth = min(560, size.width * 0.34)
|
||||||
|
let cardHeight = min(350, max(240, size.height - 260))
|
||||||
|
#else
|
||||||
let cardWidth = min(340, size.width * 0.84)
|
let cardWidth = min(340, size.width * 0.84)
|
||||||
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
|
||||||
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
|
||||||
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
// line below the strip is gone — it only re-printed what the centered card already shows.)
|
||||||
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
|
||||||
|
#endif
|
||||||
VStack(spacing: compact ? 8 : 10) {
|
VStack(spacing: compact ? 8 : 10) {
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
|
||||||
@@ -145,7 +160,7 @@ struct GamepadHomeView: View {
|
|||||||
|
|
||||||
private var titleBar: some View {
|
private var titleBar: some View {
|
||||||
Text("Select a Host")
|
Text("Select a Host")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.overlay(alignment: .trailing) {
|
.overlay(alignment: .trailing) {
|
||||||
@@ -158,6 +173,14 @@ struct GamepadHomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var cardSpacing: CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
44
|
||||||
|
#else
|
||||||
|
30
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Carousel
|
// MARK: - Carousel
|
||||||
|
|
||||||
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
|
||||||
@@ -165,7 +188,7 @@ struct GamepadHomeView: View {
|
|||||||
items: tiles,
|
items: tiles,
|
||||||
selection: $selection,
|
selection: $selection,
|
||||||
itemWidth: cardWidth,
|
itemWidth: cardWidth,
|
||||||
spacing: 30,
|
spacing: cardSpacing,
|
||||||
onActivate: { $0.activate() },
|
onActivate: { $0.activate() },
|
||||||
onSecondary: { openLibraryForSelected() },
|
onSecondary: { openLibraryForSelected() },
|
||||||
onTertiary: { showSettings = true },
|
onTertiary: { showSettings = true },
|
||||||
@@ -272,6 +295,31 @@ private struct GamepadHostTile: View {
|
|||||||
let tile: HomeTile
|
let tile: HomeTile
|
||||||
let size: CGSize
|
let size: CGSize
|
||||||
|
|
||||||
|
// 10-foot metrics on tvOS, in-hand metrics elsewhere — one tile, two viewing distances.
|
||||||
|
#if os(tvOS)
|
||||||
|
private static let titleFont: CGFloat = 33
|
||||||
|
private static let subtitleFont: CGFloat = 19
|
||||||
|
private static let statusFont: CGFloat = 15
|
||||||
|
private static let pipSide: CGFloat = 12
|
||||||
|
private static let badgeSide: CGFloat = 70
|
||||||
|
private static let badgeCorner: CGFloat = 19
|
||||||
|
private static let monogramFont: CGFloat = 34
|
||||||
|
private static let iconFont: CGFloat = 32
|
||||||
|
private static let pad: CGFloat = 28
|
||||||
|
private static let corner: CGFloat = 30
|
||||||
|
#else
|
||||||
|
private static let titleFont: CGFloat = 23
|
||||||
|
private static let subtitleFont: CGFloat = 13
|
||||||
|
private static let statusFont: CGFloat = 11
|
||||||
|
private static let pipSide: CGFloat = 9
|
||||||
|
private static let badgeSide: CGFloat = 52
|
||||||
|
private static let badgeCorner: CGFloat = 15
|
||||||
|
private static let monogramFont: CGFloat = 25
|
||||||
|
private static let iconFont: CGFloat = 24
|
||||||
|
private static let pad: CGFloat = 20
|
||||||
|
private static let corner: CGFloat = 26
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@@ -282,38 +330,38 @@ private struct GamepadHostTile: View {
|
|||||||
HStack(spacing: 7) {
|
HStack(spacing: 7) {
|
||||||
if tile.isPaired {
|
if tile.isPaired {
|
||||||
Image(systemName: "lock.fill")
|
Image(systemName: "lock.fill")
|
||||||
.font(.system(size: 11, weight: .semibold))
|
.font(.system(size: Self.statusFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(0.5))
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
}
|
}
|
||||||
if tile.isOnline {
|
if tile.isOnline {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color.green)
|
.fill(Color.green)
|
||||||
.frame(width: 9, height: 9)
|
.frame(width: Self.pipSide, height: Self.pipSide)
|
||||||
.shadow(color: .green.opacity(0.7), radius: 5)
|
.shadow(color: .green.opacity(0.7), radius: 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Text(tile.title)
|
Text(tile.title)
|
||||||
.font(.geist(23, .bold, relativeTo: .title2))
|
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.7)
|
||||||
Text(tile.subtitle)
|
Text(tile.subtitle)
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(Self.subtitleFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(Self.pad)
|
||||||
.frame(width: size.width, height: size.height, alignment: .leading)
|
.frame(width: size.width, height: size.height, alignment: .leading)
|
||||||
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
|
||||||
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous),
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
|
||||||
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
|
||||||
.strokeBorder(
|
.strokeBorder(
|
||||||
LinearGradient(
|
LinearGradient(
|
||||||
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
colors: [.white.opacity(0.22), .white.opacity(0.04)],
|
||||||
@@ -324,7 +372,7 @@ private struct GamepadHostTile: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var monogramBadge: some View {
|
private var monogramBadge: some View {
|
||||||
let shape = RoundedRectangle(cornerRadius: 15, style: .continuous)
|
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
|
||||||
return ZStack {
|
return ZStack {
|
||||||
shape.fill(tile.filled
|
shape.fill(tile.filled
|
||||||
? AnyShapeStyle(LinearGradient(
|
? AnyShapeStyle(LinearGradient(
|
||||||
@@ -335,15 +383,15 @@ private struct GamepadHostTile: View {
|
|||||||
ProgressView().tint(.white)
|
ProgressView().tint(.white)
|
||||||
} else if let icon = tile.icon {
|
} else if let icon = tile.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 24, weight: .semibold))
|
.font(.system(size: Self.iconFont, weight: .semibold))
|
||||||
.foregroundStyle(Color.brand)
|
.foregroundStyle(Color.brand)
|
||||||
} else {
|
} else {
|
||||||
Text(monogram(tile.title))
|
Text(monogram(tile.title))
|
||||||
.font(.geistFixed(25, .bold))
|
.font(.geistFixed(Self.monogramFont, .bold))
|
||||||
.foregroundStyle(tile.filled ? .white : Color.brand)
|
.foregroundStyle(tile.filled ? .white : Color.brand)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 52, height: 52)
|
.frame(width: Self.badgeSide, height: Self.badgeSide)
|
||||||
.overlay {
|
.overlay {
|
||||||
if !tile.filled {
|
if !tile.filled {
|
||||||
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
shape.strokeBorder(Color.brand.opacity(0.5), lineWidth: 1)
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS): a controller-driven focus list for
|
// The vertical sibling of GamepadCarousel (iOS/iPadOS/macOS/tvOS): a controller-driven focus list
|
||||||
// the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves a
|
// for the gamepad UI's form-like screens (GamepadSettingsView, GamepadAddHostView). Up/down moves
|
||||||
// focus bar through the rows, left/right adjusts the focused row's value, A activates it, B backs
|
// a focus bar through the rows, left/right adjusts the focused row's value, A activates it, B
|
||||||
// out. The CALLER owns each row's look (it gets the focused flag); this component owns the focus
|
// backs out. The CALLER owns each row's look (it gets the focused flag); this component owns the
|
||||||
// cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
// focus cursor, controller polling, haptics, and keeping the focused row scrolled into view.
|
||||||
|
//
|
||||||
|
// On tvOS the rows are focusable Buttons and the NATIVE FOCUS ENGINE replaces the poll entirely
|
||||||
|
// (Siri Remote and pads both drive it: up/down moves focus, select activates, Menu — via
|
||||||
|
// onExitCommand — backs out). Left/right value-adjust isn't wired there; select cycles a value
|
||||||
|
// forward exactly like A does elsewhere, the standard tvOS settings interaction. The iOS/macOS
|
||||||
|
// poll-driven behavior is untouched by the tvOS mode.
|
||||||
//
|
//
|
||||||
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
// Unlike the carousel there is no snapping and no `.scrollPosition` two-way binding to fight: the
|
||||||
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
// cursor is plainly authoritative, the scroll view just chases it with `scrollTo`. Touch stays a
|
||||||
@@ -16,7 +22,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
|
|
||||||
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hashable {
|
||||||
let items: [Item]
|
let items: [Item]
|
||||||
@@ -36,6 +42,15 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
|
|
||||||
@State private var input = GamepadMenuInput(manager: .shared)
|
@State private var input = GamepadMenuInput(manager: .shared)
|
||||||
@State private var haptics = MenuHaptics(manager: .shared)
|
@State private var haptics = MenuHaptics(manager: .shared)
|
||||||
|
#if os(tvOS)
|
||||||
|
/// tvOS: the focus engine is the navigation authority for UP/DOWN — `cursor` chases this, so
|
||||||
|
/// the caller's `focused` row styling always matches real system focus. LEFT/RIGHT adjust
|
||||||
|
/// comes from the POLL (see `wire`), never from `.onMoveCommand`: the command stream is
|
||||||
|
/// 4-way with no axis data (diagonal scroll wobble buckets into left/right), and its
|
||||||
|
/// interception of up/down proved INPUT-SOURCE-DEPENDENT on hardware — keyboard arrows were
|
||||||
|
/// intercepted but a pad's dpad was not, so programmatic stepping double-moved every press.
|
||||||
|
@FocusState private var focusedID: Item.ID?
|
||||||
|
#endif
|
||||||
/// Authoritative focus cursor (index into `items`).
|
/// Authoritative focus cursor (index into `items`).
|
||||||
@State private var cursor = 0
|
@State private var cursor = 0
|
||||||
/// A short vertical recoil when a move is refused at a list end.
|
/// A short vertical recoil when a move is refused at a list end.
|
||||||
@@ -51,10 +66,23 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
LazyVStack(spacing: 6) {
|
LazyVStack(spacing: 6) {
|
||||||
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
||||||
|
#if os(tvOS)
|
||||||
|
// A focusable Button per row: the engine moves between them, select
|
||||||
|
// activates (`tap` keeps the cursor in step before firing). The row's
|
||||||
|
// own `focused` styling is the focus treatment — the bare style adds
|
||||||
|
// no system chrome on top of it.
|
||||||
|
Button { tap(idx) } label: {
|
||||||
|
row(item, focusedID == item.id)
|
||||||
|
}
|
||||||
|
.buttonStyle(ConsoleBareButtonStyle())
|
||||||
|
.focused($focusedID, equals: item.id)
|
||||||
|
.id(item.id)
|
||||||
|
#else
|
||||||
row(item, idx == cursor && isActive)
|
row(item, idx == cursor && isActive)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { tap(idx) }
|
.onTapGesture { tap(idx) }
|
||||||
.id(item.id)
|
.id(item.id)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -69,6 +97,20 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// Focus moved (remote swipe / pad dpad) — keep the cursor, the caller's focusID mirror,
|
||||||
|
// and the controller detent in step. Menu = the list's back action (both tvOS callers
|
||||||
|
// pass one; the screen behind would otherwise catch the press and peel too far).
|
||||||
|
.onChange(of: focusedID) { _, newValue in
|
||||||
|
guard let id = newValue, let idx = items.firstIndex(where: { $0.id == id }),
|
||||||
|
idx != cursor else { return }
|
||||||
|
cursor = idx
|
||||||
|
focusID = id
|
||||||
|
haptics.move()
|
||||||
|
}
|
||||||
|
.defaultFocus($focusedID, items.first?.id)
|
||||||
|
.onExitCommand { onBack?() }
|
||||||
|
#endif
|
||||||
.sensoryFeedback(.selection, trigger: cursor)
|
.sensoryFeedback(.selection, trigger: cursor)
|
||||||
.sensoryFeedback(.selection, trigger: adjustTick)
|
.sensoryFeedback(.selection, trigger: adjustTick)
|
||||||
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
.sensoryFeedback(.impact(weight: .medium), trigger: activateTick)
|
||||||
@@ -102,6 +144,22 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
// MARK: - Input wiring
|
// MARK: - Input wiring
|
||||||
|
|
||||||
private func wire() {
|
private func wire() {
|
||||||
|
#if os(tvOS)
|
||||||
|
// The focus engine owns up/down and select (Button rows) and Menu (onExitCommand) — the
|
||||||
|
// poll carries ONLY the horizontal axis, where its dominant-axis deadzone + hold-repeat
|
||||||
|
// are exactly the adjust feel the other platforms have, and where the focus engine has
|
||||||
|
// nothing to move to in a vertical list. Vertical poll directions are deliberately
|
||||||
|
// dropped: acting on them would double the engine's own focus moves. (The Siri Remote
|
||||||
|
// never reaches this poll — no extended profile — so remote users cycle values with
|
||||||
|
// select instead, which `activate` already does.)
|
||||||
|
input.onMove = { direction in
|
||||||
|
switch direction {
|
||||||
|
case .left: adjust(by: -1)
|
||||||
|
case .right: adjust(by: 1)
|
||||||
|
case .up, .down: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
input.onMove = { direction in
|
input.onMove = { direction in
|
||||||
switch direction {
|
switch direction {
|
||||||
case .up: step(by: -1)
|
case .up: step(by: -1)
|
||||||
@@ -112,6 +170,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
}
|
}
|
||||||
input.onConfirm = { activate() }
|
input.onConfirm = { activate() }
|
||||||
input.onBack = onBack
|
input.onBack = onBack
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func step(by delta: Int) {
|
private func step(by delta: Int) {
|
||||||
@@ -123,6 +182,7 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
haptics.move()
|
haptics.move()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func adjust(by delta: Int) {
|
private func adjust(by delta: Int) {
|
||||||
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
guard let onAdjust, cursor >= 0, cursor < items.count else { return }
|
||||||
if onAdjust(items[cursor], delta) {
|
if onAdjust(items[cursor], delta) {
|
||||||
@@ -165,6 +225,12 @@ struct GamepadMenuList<Item: Identifiable, Row: View>: View where Item.ID: Hasha
|
|||||||
cursor = min(max(cursor, 0), items.count - 1)
|
cursor = min(max(cursor, 0), items.count - 1)
|
||||||
focusID = items[cursor].id
|
focusID = items[cursor].id
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// Keep real focus on the reconciled row when its old target vanished from the list.
|
||||||
|
if focusedID == nil || !items.contains(where: { $0.id == focusedID }), cursor < items.count {
|
||||||
|
focusedID = items[cursor].id
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func boundaryBump(forward: Bool) {
|
private func boundaryBump(forward: Bool) {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ struct HomeView: View {
|
|||||||
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
/// Explicit Wake-on-LAN of an offline host — fires the packet and waits for it to come online
|
||||||
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||||
let wake: (StoredHost) -> Void
|
let wake: (StoredHost) -> Void
|
||||||
/// Experimental game-library browser (gated) — the host-card "Browse Library…" action.
|
/// Game-library browser (default ON; the Settings toggle opts out) — the host-card
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
/// "Browse Library…" action.
|
||||||
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
/// The host being edited (name / address / port / Wake-on-LAN MAC) — drives the edit sheet.
|
||||||
@State private var editTarget: StoredHost?
|
@State private var editTarget: StoredHost?
|
||||||
|
|
||||||
@@ -48,6 +49,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
// Mirror of the action row's focusSection below: an UPWARD move from
|
||||||
|
// the centered buttons must land back in the grid even when no card
|
||||||
|
// sits in the buttons' columns (a lone top-left card, say). The grid
|
||||||
|
// spans the row, so the section catches every upward ray.
|
||||||
|
#if os(tvOS)
|
||||||
|
.focusSection()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
if !discoveredUnsaved.isEmpty {
|
if !discoveredUnsaved.isEmpty {
|
||||||
discoveredSection
|
discoveredSection
|
||||||
@@ -67,6 +75,14 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
|
// One FULL-WIDTH focus target for any downward move out of the grid.
|
||||||
|
// focusSection alone is not enough: the engine tests the section's
|
||||||
|
// FRAME, and a content-hugging centered HStack only overlaps the middle
|
||||||
|
// columns — a swipe down from an outer card dead-ends and the actions
|
||||||
|
// are unreachable by remote. Stretching the section across the row means
|
||||||
|
// every column's downward ray hits it.
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.focusSection()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,6 +214,10 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.padding([.horizontal, .bottom])
|
.padding([.horizontal, .bottom])
|
||||||
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
.padding(.top, store.hosts.isEmpty ? 0 : 8)
|
||||||
|
// Same reachability contract as the saved grid above — see its focusSection comment.
|
||||||
|
#if os(tvOS)
|
||||||
|
.focusSection()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
/// Discovered hosts not already saved (see `HostDiscovery.unsaved` — shared with the gamepad
|
||||||
@@ -259,7 +279,9 @@ struct HomeView: View {
|
|||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
[GridItem(.adaptive(minimum: 250, maximum: 320), spacing: 16)]
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
[GridItem(.adaptive(minimum: 320), spacing: 48)]
|
// Tracks CardMetrics' 10-foot sizes — at the 30pt name a 320pt column truncates
|
||||||
|
// every hostname longer than ~10 characters.
|
||||||
|
[GridItem(.adaptive(minimum: 460), spacing: 48)]
|
||||||
#else
|
#else
|
||||||
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
[GridItem(.adaptive(minimum: 280), spacing: 16)]
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ private struct CardMetrics {
|
|||||||
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11,
|
||||||
padding: 16, spacing: 14, radius: 12)
|
padding: 16, spacing: 14, radius: 12)
|
||||||
#elseif os(tvOS)
|
#elseif os(tvOS)
|
||||||
CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14,
|
// 10-foot sizes — the 24pt-name tier read like a phone card from the couch.
|
||||||
padding: 18, spacing: 18, radius: 14)
|
CardMetrics(tile: 84, monogram: 42, name: 30, meta: 20, status: 17,
|
||||||
|
padding: 24, spacing: 22, radius: 18)
|
||||||
#else
|
#else
|
||||||
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5,
|
||||||
padding: 13, spacing: 12, radius: 10)
|
padding: 13, spacing: 12, radius: 10)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS — see LibraryView's
|
// The gamepad-driven presentation of the game library (iOS/iPadOS/macOS/tvOS — see LibraryView's
|
||||||
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
// `gamepadUIActive` branch): a classic coverflow instead of the touch grid. All the
|
||||||
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
// scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the coverflow card
|
||||||
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
// (poster + the 3D recede treatment via `.scrollTransition`), the "now focused" detail panel, and
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
struct LibraryCoverflowView: View {
|
struct LibraryCoverflowView: View {
|
||||||
|
|||||||
@@ -21,9 +21,9 @@ struct LibraryView: View {
|
|||||||
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
/// list fetch, reused across every poster in the grid). Built alongside `games` in `load()`;
|
||||||
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
/// torn down on disappear since it isn't one-shot like `LibraryClient.fetch`'s own session.
|
||||||
@State private var imageSession: URLSession?
|
@State private var imageSession: URLSession?
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
// Gamepad-driven browsing (iOS/iPadOS/macOS) — see ContentView's identical gate. tvOS keeps
|
// Gamepad-driven browsing — see ContentView's identical gate. With no controller (or the
|
||||||
// its existing plain-grid presentation of this same view unchanged.
|
// setting off) every platform keeps the plain-grid presentation of this same view.
|
||||||
@ObservedObject private var gamepadManager = GamepadManager.shared
|
@ObservedObject private var gamepadManager = GamepadManager.shared
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
private var gamepadUIActive: Bool {
|
private var gamepadUIActive: Bool {
|
||||||
@@ -69,7 +69,6 @@ struct LibraryView: View {
|
|||||||
} else if games.isEmpty {
|
} else if games.isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
#if os(iOS) || os(macOS)
|
|
||||||
if gamepadUIActive {
|
if gamepadUIActive {
|
||||||
LibraryCoverflowView(
|
LibraryCoverflowView(
|
||||||
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
games: games, imageSession: imageSession, onLaunch: onLaunch,
|
||||||
@@ -77,9 +76,6 @@ struct LibraryView: View {
|
|||||||
} else {
|
} else {
|
||||||
grid
|
grid
|
||||||
}
|
}
|
||||||
#else
|
|
||||||
grid
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,10 +38,22 @@ struct PunktfunkClientApp: App {
|
|||||||
ContentView()
|
ContentView()
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
// NOT on tvOS: under the tvOS 26 glass button style a tinted UNFOCUSED control fills
|
||||||
|
// AND labels itself in the tint — every plain Button/TextField renders as a blank
|
||||||
|
// brand-violet pill until focused. Untinted, tvOS keeps the system glass look
|
||||||
|
// (visible labels, white focus lift); brand color stays on explicit Color.brand uses.
|
||||||
|
#if !os(tvOS)
|
||||||
.tint(.brand)
|
.tint(.brand)
|
||||||
|
#endif
|
||||||
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
// Geist Sans is the app's typeface. This sets the default for unstyled text and the
|
||||||
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
// form row labels; views that pick an explicit size/weight use `.geist(…)` directly.
|
||||||
|
// tvOS reads from across the room: its system body is 29pt, so pinning the phone's
|
||||||
|
// 17pt there shrank every unstyled control (rows, fields, buttons) to postage size.
|
||||||
|
#if os(tvOS)
|
||||||
|
.font(.geist(29, relativeTo: .body))
|
||||||
|
#else
|
||||||
.font(.geist(17, relativeTo: .body))
|
.font(.geist(17, relativeTo: .body))
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
// The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S —
|
// The Stream menu (Release Mouse ⌃⌥⇧Q, Disconnect ⌃⌥⇧D, Show/Hide Statistics ⌃⌥⇧S —
|
||||||
// the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard
|
// the cross-client Ctrl+Alt+Shift set) — a real menu bar on macOS, hardware-keyboard
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// handshake phase, and the pump-thread → main-actor stats relay.
|
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -10,6 +11,15 @@ import SwiftUI
|
|||||||
#elseif canImport(UIKit)
|
#elseif canImport(UIKit)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||||||
|
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||||
|
/// instrument: any visible overlay forces the metal layer through the compositor, which costs a
|
||||||
|
/// refresh period on the vsync-latched platforms — this is how to measure with it off.
|
||||||
|
private let statsLog = Logger(subsystem: "io.unom.punktfunk", category: "stats")
|
||||||
|
|
||||||
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
/// Pump-thread-side frame counters; a 1 Hz main-actor timer drains them into @Published
|
||||||
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
/// values. NSLock instead of an actor — the writer is the (non-async) pump thread.
|
||||||
@@ -119,6 +129,12 @@ final class SessionModel: ObservableObject {
|
|||||||
private var audio: SessionAudio?
|
private var audio: SessionAudio?
|
||||||
private var gamepadCapture: GamepadCapture?
|
private var gamepadCapture: GamepadCapture?
|
||||||
private var gamepadFeedback: GamepadFeedback?
|
private var gamepadFeedback: GamepadFeedback?
|
||||||
|
#if os(tvOS)
|
||||||
|
/// Siri Remote → host pointer while streaming (touch surface moves, press = left click,
|
||||||
|
/// Play/Pause = right click) + the remote's deliberate exit (hold Back ≥ 1 s). See
|
||||||
|
/// SiriRemotePointer — same trust gate/lifecycle as the gamepad capture above.
|
||||||
|
private var remotePointer: SiriRemotePointer?
|
||||||
|
#endif
|
||||||
|
|
||||||
var isBusy: Bool { phase != .idle }
|
var isBusy: Bool { phase != .idle }
|
||||||
|
|
||||||
@@ -163,6 +179,14 @@ final class SessionModel: ObservableObject {
|
|||||||
let displayHDR: Bool = {
|
let displayHDR: Bool = {
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
return (NSScreen.main?.maximumExtendedDynamicRangeColorComponentValue ?? 1.0) > 1.0
|
||||||
|
#elseif os(tvOS)
|
||||||
|
// NOT the EDR headroom here: on tvOS that reflects the CURRENT output mode, and
|
||||||
|
// Apple's recommended setup runs an SDR home screen with Match Content — an
|
||||||
|
// HDR-capable TV would read 1.0 at connect time and never be advertised. The
|
||||||
|
// session switches the display to HDR10 itself once streaming (AVDisplayManager —
|
||||||
|
// see StreamViewIOS), so gate on the TV's mode-independent capability; if the
|
||||||
|
// switch never lands, the presenter's in-shader tone-map keeps PQ safe anyway.
|
||||||
|
return AVPlayer.eligibleForHDRPlayback
|
||||||
#else
|
#else
|
||||||
return UIScreen.main.potentialEDRHeadroom > 1.0
|
return UIScreen.main.potentialEDRHeadroom > 1.0
|
||||||
#endif
|
#endif
|
||||||
@@ -300,6 +324,10 @@ final class SessionModel: ObservableObject {
|
|||||||
// connection is still up); the feedback drain joins off-main like audio.
|
// connection is still up); the feedback drain joins off-main like audio.
|
||||||
gamepadCapture?.stop()
|
gamepadCapture?.stop()
|
||||||
gamepadCapture = nil
|
gamepadCapture = nil
|
||||||
|
#if os(tvOS)
|
||||||
|
remotePointer?.stop() // releases any held click while the connection is still up
|
||||||
|
remotePointer = nil
|
||||||
|
#endif
|
||||||
let feedback = gamepadFeedback
|
let feedback = gamepadFeedback
|
||||||
gamepadFeedback = nil
|
gamepadFeedback = nil
|
||||||
if let conn = connection {
|
if let conn = connection {
|
||||||
@@ -363,11 +391,20 @@ final class SessionModel: ObservableObject {
|
|||||||
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
// session's virtual pad is a DualSense). Same trust gate as audio — nothing is
|
||||||
// forwarded during the trust prompt.
|
// forwarded during the trust prompt.
|
||||||
let capture = GamepadCapture(connection: conn, manager: .shared)
|
let capture = GamepadCapture(connection: conn, manager: .shared)
|
||||||
|
// 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() }
|
||||||
capture.start()
|
capture.start()
|
||||||
gamepadCapture = capture
|
gamepadCapture = capture
|
||||||
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
let feedback = GamepadFeedback(connection: conn, manager: .shared)
|
||||||
feedback.start()
|
feedback.start()
|
||||||
gamepadFeedback = feedback
|
gamepadFeedback = feedback
|
||||||
|
#if os(tvOS)
|
||||||
|
let pointer = SiriRemotePointer(connection: conn)
|
||||||
|
pointer.onDisconnectRequest = { [weak self] in self?.disconnect() }
|
||||||
|
pointer.start()
|
||||||
|
remotePointer = pointer
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startStatsTimer() {
|
private func startStatsTimer() {
|
||||||
@@ -429,12 +466,32 @@ final class SessionModel: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.decodeValid = false
|
self.decodeValid = false
|
||||||
}
|
}
|
||||||
if let d = self.displayStage.drain() {
|
let displayWindow = self.displayStage.drain()
|
||||||
|
if let d = displayWindow {
|
||||||
self.displayP50Ms = d.p50Ms
|
self.displayP50Ms = d.p50Ms
|
||||||
self.displayValid = true
|
self.displayValid = true
|
||||||
} else {
|
} else {
|
||||||
self.displayValid = false
|
self.displayValid = false
|
||||||
}
|
}
|
||||||
|
// Mirror the window to the unified log (see statsLog) — one line per second,
|
||||||
|
// stages in ms, only while frames actually flowed. `fps` counts RECEIVED AUs;
|
||||||
|
// `presents` counts frames that reached glass (the display meter's sample count)
|
||||||
|
// — a presents≪fps gap is the presenter dropping/serializing, an fps deficit is
|
||||||
|
// upstream (host capture/encode or the network).
|
||||||
|
if frames > 0 {
|
||||||
|
let line = String(
|
||||||
|
format: "fps=%d presents=%d e2e_p50=%.1f e2e_p95=%.1f hostnet_p50=%.1f "
|
||||||
|
+ "decode_p50=%.1f display_p50=%.1f lost=%d",
|
||||||
|
frames,
|
||||||
|
displayWindow?.count ?? 0,
|
||||||
|
self.endToEndValid ? self.endToEndP50Ms : -1,
|
||||||
|
self.endToEndValid ? self.endToEndP95Ms : -1,
|
||||||
|
self.hostNetworkValid ? self.hostNetworkP50Ms : -1,
|
||||||
|
self.decodeValid ? self.decodeP50Ms : -1,
|
||||||
|
self.displayValid ? self.displayP50Ms : -1,
|
||||||
|
lost)
|
||||||
|
statsLog.info("\(line, privacy: .public)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// .common so the HUD keeps updating during window drags / menu tracking.
|
// .common so the HUD keeps updating during window drags / menu tracking.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ struct StreamHUDView: View {
|
|||||||
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
// this card — its frame (and, on iOS, its clamped corner) animate to the new size — rather
|
||||||
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
// than cross-fading a whole new card in. Only the inner content switches per tier.
|
||||||
tierContent
|
tierContent
|
||||||
.padding(10)
|
.padding(cardPadding)
|
||||||
.glassBackground(cardShape)
|
.glassBackground(cardShape)
|
||||||
.padding(edgeInset)
|
.padding(edgeInset)
|
||||||
}
|
}
|
||||||
@@ -145,36 +145,43 @@ struct StreamHUDView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if os(tvOS)
|
|
||||||
// No focusable control during play: a focusable button steals the controller's
|
|
||||||
// A press (the focus engine consumes it before the host sees it). Disconnect is
|
|
||||||
// the Siri Remote's Menu button (.onExitCommand on the stream) — just hint it.
|
|
||||||
Text("Press Menu to disconnect")
|
|
||||||
.font(.geist(12, relativeTo: .caption))
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
#else
|
|
||||||
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||||
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||||
// click-to-disconnect affordance.
|
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
|
||||||
|
// focusable control would steal the controller's A press from the host) NOR a hint
|
||||||
|
// line: the exits are the hold gestures the start-of-stream banner teaches (hold
|
||||||
|
// the remote's Back; hold L1+R1+Start+Select on a pad).
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
Button("Disconnect (⌃⌥⇧D)") { model.disconnect() }
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#else
|
#elseif os(iOS)
|
||||||
Button("Disconnect") { model.disconnect() }
|
Button("Disconnect") { model.disconnect() }
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(12, relativeTo: .caption))
|
||||||
#endif
|
#endif
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Card metrics
|
// MARK: - Card metrics
|
||||||
|
|
||||||
/// The OUTER gap between the card and the screen edge. (Inner content padding stays a fixed 10.)
|
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||||
/// On iOS the card hugs a physically rounded display corner, so it sits a little further in and
|
/// couch (relative system styles), so the card's chrome must keep pace or it reads cramped.
|
||||||
/// pairs with a concentric corner radius (below); on macOS/tvOS windows the classic 10 reads fine.
|
private var cardPadding: CGFloat {
|
||||||
|
#if os(tvOS)
|
||||||
|
return 16
|
||||||
|
#else
|
||||||
|
return 10
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The OUTER gap between the card and the screen edge. On iOS the card hugs a physically
|
||||||
|
/// rounded display corner, so it sits a little further in and pairs with a concentric corner
|
||||||
|
/// radius (below); tvOS floats it well clear of the TV's overscan-ish edge; macOS windows
|
||||||
|
/// keep the classic 10.
|
||||||
private var edgeInset: CGFloat {
|
private var edgeInset: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return 14
|
return 14
|
||||||
|
#elseif os(tvOS)
|
||||||
|
return 24
|
||||||
#else
|
#else
|
||||||
return 10
|
return 10
|
||||||
#endif
|
#endif
|
||||||
@@ -187,6 +194,8 @@ struct StreamHUDView: View {
|
|||||||
private var cardCornerRadius: CGFloat {
|
private var cardCornerRadius: CGFloat {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
return max(12, DeviceMetrics.displayCornerRadius - edgeInset)
|
||||||
|
#elseif os(tvOS)
|
||||||
|
return 16 // scales with the roomier padding
|
||||||
#else
|
#else
|
||||||
return 10
|
return 10
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Open-source acknowledgements: punktfunk's own license (MIT OR Apache-2.0) followed by the
|
/// Open-source acknowledgements: Punktfunk's own license (MIT OR Apache-2.0) followed by the
|
||||||
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
/// third-party software notices. Used as a pushed view on iOS/tvOS and a preferences tab on macOS.
|
||||||
struct AcknowledgementsView: View {
|
struct AcknowledgementsView: View {
|
||||||
private var version: String? {
|
private var version: String? {
|
||||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TV-legible sizes for the explicitly-sized text; the in-hand sizes elsewhere. (The license
|
||||||
|
// walls use relative system styles, which already scale per platform.)
|
||||||
|
#if os(tvOS)
|
||||||
|
private static let titleFont: CGFloat = 36
|
||||||
|
private static let headlineFont: CGFloat = 26
|
||||||
|
private static let captionFont: CGFloat = 20
|
||||||
|
#else
|
||||||
|
private static let titleFont: CGFloat = 22
|
||||||
|
private static let headlineFont: CGFloat = 17
|
||||||
|
private static let captionFont: CGFloat = 12
|
||||||
|
#endif
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
// Top-level LazyVStack so the third-party-notices chunks (Licenses.thirdPartyNoticesChunks,
|
||||||
@@ -16,42 +28,40 @@ struct AcknowledgementsView: View {
|
|||||||
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
// notice chunks visually continuous; the header block carries its own spacing + bottom pad.
|
||||||
LazyVStack(alignment: .leading, spacing: 0) {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
Text("punktfunk")
|
Text("Punktfunk")
|
||||||
.font(.geist(22, .bold, relativeTo: .title2))
|
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
|
||||||
if let version {
|
if let version {
|
||||||
Text("Version \(version)")
|
Text("Version \(version)")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(Licenses.appLicense)
|
LicenseWall(text: Licenses.appLicense)
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.modifier(SelectableText())
|
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Bundled font")
|
Text("Bundled font")
|
||||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
Text("punktfunk ships the Geist typeface (Geist Sans), "
|
Text("Punktfunk ships the Geist typeface (Geist Sans), "
|
||||||
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
+ "© The Geist Project Authors / Vercel, used under the SIL Open Font "
|
||||||
+ "License 1.1.")
|
+ "License 1.1.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if !Licenses.fontLicense.isEmpty {
|
if !Licenses.fontLicense.isEmpty {
|
||||||
Text(Licenses.fontLicense)
|
LicenseWall(text: Licenses.fontLicense)
|
||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.modifier(SelectableText())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
Text("Third-party software")
|
Text("Third-party software")
|
||||||
.font(.geist(17, .semibold, relativeTo: .headline))
|
.font(.geist(Self.headlineFont, .semibold, relativeTo: .headline))
|
||||||
Text(
|
Text(
|
||||||
"punktfunk uses the open-source components below, each under its own license. "
|
"Punktfunk uses the open-source components below, each under its own license. "
|
||||||
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
+ "On some platforms FFmpeg is additionally bundled under the LGPL v2.1+ "
|
||||||
+ "(dynamically linked, replaceable)."
|
+ "(dynamically linked, replaceable)."
|
||||||
)
|
)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(Self.captionFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -62,6 +72,7 @@ struct AcknowledgementsView: View {
|
|||||||
.font(.caption2.monospaced())
|
.font(.caption2.monospaced())
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.modifier(SelectableText())
|
.modifier(SelectableText())
|
||||||
|
.modifier(TVFocusable())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 900, alignment: .leading)
|
.frame(maxWidth: 900, alignment: .leading)
|
||||||
@@ -85,3 +96,40 @@ private struct SelectableText: ViewModifier {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Focus IS scrolling on tvOS: with nothing focusable in this pushed screen the license wall
|
||||||
|
/// couldn't move at all, and a Menu press had nothing inside the NavigationStack to route
|
||||||
|
/// through — it suspended the whole app instead of popping. Plain (non-interactive) focusability
|
||||||
|
/// on every license/notice chunk fixes both; a chunk is sized to about two thirds of a screen
|
||||||
|
/// (see Licenses.chunked), so each focus step reads as a page turn. The chunks must be SMALL
|
||||||
|
/// focus stops all the way down — one tall focusable block would strand focus at its top and the
|
||||||
|
/// next stop could sit past the LazyVStack's instantiation window.
|
||||||
|
private struct TVFocusable: ViewModifier {
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
content.focusable()
|
||||||
|
#else
|
||||||
|
content
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One license wall: a single selectable Text on touch/desktop; on tvOS, focus-page-sized
|
||||||
|
/// chunks (see TVFocusable). The caller's `.font` cascades into either form.
|
||||||
|
private struct LicenseWall: View {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
let chunks = Licenses.chunked(text)
|
||||||
|
ForEach(chunks.indices, id: \.self) { i in
|
||||||
|
Text(chunks[i])
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.modifier(TVFocusable())
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
Text(text)
|
||||||
|
.modifier(SelectableText())
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// The gamepad-driven settings screen (iOS/iPadOS/macOS): the couch-relevant subset of SettingsView,
|
// The gamepad-driven settings screen (iOS/iPadOS/macOS/tvOS): the couch-relevant subset of SettingsView,
|
||||||
// restyled as a console settings page and fully navigable with a controller — up/down moves the
|
// restyled as a console settings page and fully navigable with a controller — up/down moves the
|
||||||
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
|
// focus bar, left/right steps the focused value, A cycles/toggles it, B closes. Shown from the
|
||||||
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
// gamepad home launcher (X); the touch SettingsView remains the full-fidelity editor (custom
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS) || os(tvOS)
|
||||||
import GameController
|
import GameController
|
||||||
|
|
||||||
struct GamepadSettingsView: View {
|
struct GamepadSettingsView: View {
|
||||||
@@ -34,8 +34,9 @@ struct GamepadSettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
@AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw
|
||||||
= StatsVerbosity.current.rawValue
|
= StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) private var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) private var gamepadUIEnabled = true
|
||||||
|
@AppStorage(DefaultsKey.presenter) private var presenter = SettingsOptions.presenterDefault
|
||||||
@ObservedObject private var gamepads = GamepadManager.shared
|
@ObservedObject private var gamepads = GamepadManager.shared
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -47,6 +48,9 @@ struct GamepadSettingsView: View {
|
|||||||
private let compact = false // no size classes on macOS; the sheet is sized generously
|
private let compact = false // no size classes on macOS; the sheet is sized generously
|
||||||
#endif
|
#endif
|
||||||
@State private var focusID: String?
|
@State private var focusID: String?
|
||||||
|
/// 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
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GamepadMenuList(
|
GamepadMenuList(
|
||||||
@@ -57,13 +61,13 @@ struct GamepadSettingsView: View {
|
|||||||
onBack: { dismiss() }
|
onBack: { dismiss() }
|
||||||
) { row, focused in
|
) { row, focused in
|
||||||
rowView(row, focused: focused)
|
rowView(row, focused: focused)
|
||||||
.frame(maxWidth: 620)
|
.frame(maxWidth: GamepadFormMetrics.rowMaxWidth)
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
Text("Settings")
|
Text("Settings")
|
||||||
.font(.geist(compact ? 20 : 30, .bold, relativeTo: .title))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
.padding(.top, gamepadTitleTopPadding(compact: compact))
|
||||||
.padding(.bottom, compact ? 4 : 8)
|
.padding(.bottom, compact ? 4 : 8)
|
||||||
@@ -74,7 +78,7 @@ struct GamepadSettingsView: View {
|
|||||||
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(focusedDetail)
|
Text(focusedDetail)
|
||||||
.font(.geist(13, relativeTo: .caption))
|
.font(.geist(GamepadFormMetrics.detailFont, relativeTo: .caption))
|
||||||
.foregroundStyle(.white.opacity(0.55))
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
.lineLimit(2, reservesSpace: true)
|
.lineLimit(2, reservesSpace: true)
|
||||||
.animation(.smooth(duration: 0.2), value: focusID)
|
.animation(.smooth(duration: 0.2), value: focusID)
|
||||||
@@ -107,61 +111,78 @@ struct GamepadSettingsView: View {
|
|||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
Button { dismiss() } label: {
|
Button { dismiss() } label: {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.system(size: GamepadFormMetrics.closeFont, weight: .semibold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.frame(width: 34, height: 34)
|
.frame(width: GamepadFormMetrics.closeSide, height: GamepadFormMetrics.closeSide)
|
||||||
.glassBackground(Circle(), interactive: true)
|
.glassBackground(Circle(), interactive: true)
|
||||||
.contentShape(Circle())
|
.contentShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.keyboardShortcut(.cancelAction)
|
#if !os(tvOS)
|
||||||
|
.keyboardShortcut(.cancelAction) // unavailable on tvOS (Menu is the cancel there)
|
||||||
|
#endif
|
||||||
.accessibilityLabel("Close settings")
|
.accessibilityLabel("Close settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Row rendering
|
// MARK: - Row rendering
|
||||||
|
|
||||||
private func rowView(_ row: Row, focused: Bool) -> some View {
|
private func rowView(_ row: Row, focused: Bool) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
let m = GamepadFormMetrics.self
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
if let header = row.header {
|
if let header = row.header {
|
||||||
Text(header)
|
Text(header)
|
||||||
.font(.geist(12, .semibold, relativeTo: .caption))
|
.font(.geist(m.headerFont, .semibold, relativeTo: .caption))
|
||||||
.tracking(1.4)
|
.tracking(1.4)
|
||||||
.foregroundStyle(.white.opacity(0.45))
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
.padding(.leading, 16)
|
.padding(.leading, m.rowHPad)
|
||||||
.padding(.top, 14)
|
.padding(.top, 14)
|
||||||
}
|
}
|
||||||
HStack(spacing: 14) {
|
HStack(spacing: 14) {
|
||||||
Image(systemName: row.icon)
|
Image(systemName: row.icon)
|
||||||
.font(.system(size: 17))
|
.font(.system(size: m.iconFont))
|
||||||
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
.foregroundStyle(focused ? Color.brand : .white.opacity(0.55))
|
||||||
.frame(width: 28)
|
.frame(width: m.iconWidth)
|
||||||
Text(row.label)
|
Text(row.label)
|
||||||
.font(.geist(16, .semibold, relativeTo: .body))
|
.font(.geist(m.labelFont, .semibold, relativeTo: .body))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer(minLength: 12)
|
Spacer(minLength: 12)
|
||||||
HStack(spacing: 9) {
|
HStack(spacing: 9) {
|
||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
.foregroundStyle(.white.opacity(focused ? 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.
|
||||||
|
// Deliberately not `.push`: that travels the whole container width, loud
|
||||||
|
// and visibly outside the row. The ZStack is the stable home the
|
||||||
|
// removed/inserted texts transition within.
|
||||||
|
let slide: CGFloat = lastAdjustDelta >= 0 ? 14 : -14
|
||||||
|
ZStack {
|
||||||
Text(row.value)
|
Text(row.value)
|
||||||
.font(.geist(15, .medium, relativeTo: .callout))
|
.font(.geist(m.valueFont, .medium, relativeTo: .callout))
|
||||||
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
.foregroundStyle(focused ? .white : .white.opacity(0.6))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.id(row.value)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .offset(x: slide).combined(with: .opacity),
|
||||||
|
removal: .offset(x: -slide).combined(with: .opacity)))
|
||||||
|
}
|
||||||
|
.animation(.smooth(duration: 0.22), value: row.value)
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: m.chevronFont, weight: .semibold))
|
||||||
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
.foregroundStyle(.white.opacity(focused ? 0.6 : 0))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, m.rowHPad)
|
||||||
.padding(.vertical, 13)
|
.padding(.vertical, m.rowVPad)
|
||||||
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
|
||||||
.consoleGlass(
|
.consoleGlass(
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous),
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous),
|
||||||
tint: focused ? Color.brand.opacity(0.30) : nil,
|
tint: focused ? Color.brand.opacity(0.30) : nil,
|
||||||
interactive: focused)
|
interactive: focused)
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
RoundedRectangle(cornerRadius: m.rowCorner, style: .continuous)
|
||||||
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
|
||||||
}
|
}
|
||||||
.scaleEffect(focused ? 1.0 : 0.98)
|
.scaleEffect(focused ? 1.0 : 0.98)
|
||||||
@@ -193,10 +214,12 @@ struct GamepadSettingsView: View {
|
|||||||
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
/// Dispatch by id so the focus list's stored input callbacks always act on freshly built rows
|
||||||
/// (never on state captured at wire time).
|
/// (never on state captured at wire time).
|
||||||
private func adjust(id: String, by delta: Int) -> Bool {
|
private func adjust(id: String, by delta: Int) -> Bool {
|
||||||
rows.first { $0.id == id }?.adjust(delta) ?? false
|
lastAdjustDelta = delta
|
||||||
|
return rows.first { $0.id == id }?.adjust(delta) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func activate(id: String) {
|
private func activate(id: String) {
|
||||||
|
lastAdjustDelta = 1 // A always cycles forward
|
||||||
rows.first { $0.id == id }?.activate()
|
rows.first { $0.id == id }?.activate()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +275,12 @@ struct GamepadSettingsView: View {
|
|||||||
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
detail: "Sharper text and UI at more bandwidth — needs host opt-in and "
|
||||||
+ "hardware decode.",
|
+ "hardware decode.",
|
||||||
value: $enable444),
|
value: $enable444),
|
||||||
|
choiceRow(
|
||||||
|
id: "presenter", icon: "rectangle.stack", label: "Presenter",
|
||||||
|
detail: "Stage 3 paces presents to the display — lowest display latency. "
|
||||||
|
+ "Stage 2 shows each frame on arrival. Applies from the next session.",
|
||||||
|
options: SettingsOptions.presenters, current: presenter
|
||||||
|
) { presenter = $0 },
|
||||||
|
|
||||||
choiceRow(
|
choiceRow(
|
||||||
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
id: "audio", header: "Audio", icon: "speaker.wave.2", label: "Audio channels",
|
||||||
@@ -287,8 +316,7 @@ struct GamepadSettingsView: View {
|
|||||||
) { hudPlacement = $0 },
|
) { hudPlacement = $0 },
|
||||||
toggleRow(
|
toggleRow(
|
||||||
id: "library", icon: "square.grid.2x2", label: "Game library",
|
id: "library", icon: "square.grid.2x2", label: "Game library",
|
||||||
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")) "
|
detail: "Browse and launch the host's games with \(buttonName(\.buttonY, "Y")).",
|
||||||
+ "(experimental).",
|
|
||||||
value: $libraryEnabled),
|
value: $libraryEnabled),
|
||||||
toggleRow(
|
toggleRow(
|
||||||
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
id: "gamepadUI", icon: "hand.tap", label: "Controller-optimized UI",
|
||||||
|
|||||||
@@ -37,6 +37,30 @@ enum SettingsOptions {
|
|||||||
static let hudPlacements: [(label: String, tag: String)] =
|
static let hudPlacements: [(label: String, tag: String)] =
|
||||||
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
HUDPlacement.allCases.map { ($0.label, $0.rawValue) }
|
||||||
|
|
||||||
|
/// Stage-2 vs stage-3 present pacing (`DefaultsKey.presenter` — see SessionPresenter's
|
||||||
|
/// PresenterChoice); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
||||||
|
static var presenters: [(label: String, tag: String)] {
|
||||||
|
var options: [(label: String, tag: String)] = [
|
||||||
|
("Stage 2", "stage2"),
|
||||||
|
("Stage 3", "stage3"),
|
||||||
|
]
|
||||||
|
#if DEBUG
|
||||||
|
options.append(("Stage 1 (debug)", "stage1"))
|
||||||
|
#endif
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The platform's presenter default (mirrors SessionPresenter's platformDefault — tvOS runs
|
||||||
|
/// glass pacing, everything else arrival). Views seed their @AppStorage display from this so
|
||||||
|
/// an untouched picker shows what actually runs.
|
||||||
|
static var presenterDefault: String {
|
||||||
|
#if os(tvOS)
|
||||||
|
"stage3"
|
||||||
|
#else
|
||||||
|
"stage2"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
/// Stats-overlay tiers (`DefaultsKey.statsVerbosity`) — the `tag` is the raw value.
|
||||||
static let statsVerbosities: [(label: String, tag: String)] =
|
static let statsVerbosities: [(label: String, tag: String)] =
|
||||||
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
StatsVerbosity.allCases.map { ($0.label, $0.rawValue) }
|
||||||
@@ -105,8 +129,8 @@ enum SettingsOptions {
|
|||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS) || os(macOS)
|
// MARK: - Stream mode (iOS/macOS pickers + the gamepad settings rows on all three; the
|
||||||
// MARK: - Stream mode (iOS + macOS pickers; tvOS builds its own preset list)
|
// touch/remote tvOS SettingsView builds its own preset list)
|
||||||
|
|
||||||
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
/// 16:9 then ultrawide presets; the device's native mode is prepended by `resolutionModes`.
|
||||||
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
static let resolutionPresets: [(name: String, w: Int, h: Int)] = [
|
||||||
@@ -124,8 +148,8 @@ enum SettingsOptions {
|
|||||||
@MainActor
|
@MainActor
|
||||||
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
static func resolutionModes() -> [(name: String, w: Int, h: Int)] {
|
||||||
var native: [(name: String, w: Int, h: Int)] = []
|
var native: [(name: String, w: Int, h: Int)] = []
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels
|
let bounds = UIScreen.main.nativeBounds // portrait-oriented pixels (tvOS: the TV mode)
|
||||||
native = [("This device",
|
native = [("This device",
|
||||||
Int(max(bounds.width, bounds.height)),
|
Int(max(bounds.width, bounds.height)),
|
||||||
Int(min(bounds.width, bounds.height)))]
|
Int(min(bounds.width, bounds.height)))]
|
||||||
@@ -145,7 +169,7 @@ enum SettingsOptions {
|
|||||||
/// the screen can't show), plus any stored custom value so it stays selectable.
|
/// the screen can't show), plus any stored custom value so it stays selectable.
|
||||||
@MainActor
|
@MainActor
|
||||||
static func refreshRates(including current: Int) -> [Int] {
|
static func refreshRates(including current: Int) -> [Int] {
|
||||||
#if os(iOS)
|
#if os(iOS) || os(tvOS)
|
||||||
let maxHz = UIScreen.main.maximumFramesPerSecond
|
let maxHz = UIScreen.main.maximumFramesPerSecond
|
||||||
#else
|
#else
|
||||||
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
let maxHz = NSScreen.main?.maximumFramesPerSecond ?? 60
|
||||||
@@ -155,5 +179,4 @@ enum SettingsOptions {
|
|||||||
if !rates.contains(current) { rates.append(current) }
|
if !rates.contains(current) { rates.append(current) }
|
||||||
return rates.sorted()
|
return rates.sorted()
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
@AppStorage(DefaultsKey.compositor) var compositor = 0
|
||||||
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
@AppStorage(DefaultsKey.gamepadType) var gamepadType = 0
|
||||||
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
@AppStorage(DefaultsKey.bitrateKbps) var bitrateKbps = 0
|
||||||
@AppStorage(DefaultsKey.presenter) var presenter = "stage2"
|
@AppStorage(DefaultsKey.presenter) var presenter = SettingsOptions.presenterDefault
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
@AppStorage(DefaultsKey.vsync) var vsync = false
|
@AppStorage(DefaultsKey.vsync) var vsync = false
|
||||||
#endif
|
#endif
|
||||||
@@ -33,7 +33,7 @@ struct SettingsView: View {
|
|||||||
#endif
|
#endif
|
||||||
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
@AppStorage(DefaultsKey.hdrEnabled) var hdrEnabled = true
|
||||||
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
@AppStorage(DefaultsKey.enable444) var enable444 = true
|
||||||
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = false
|
@AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true
|
||||||
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
|
||||||
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
|
||||||
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
|
||||||
@@ -43,9 +43,7 @@ struct SettingsView: View {
|
|||||||
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
@AppStorage(DefaultsKey.statsVerbosity) var statsVerbosityRaw = StatsVerbosity.current.rawValue
|
||||||
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
@AppStorage(DefaultsKey.hudPlacement) var hudPlacement = HUDPlacement.topTrailing.rawValue
|
||||||
@ObservedObject var gamepads = GamepadManager.shared
|
@ObservedObject var gamepads = GamepadManager.shared
|
||||||
#if !os(tvOS)
|
|
||||||
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
@AppStorage(DefaultsKey.gamepadUIEnabled) var gamepadUIEnabled = true
|
||||||
#endif
|
|
||||||
#if DEBUG && !os(tvOS)
|
#if DEBUG && !os(tvOS)
|
||||||
@State var showControllerTest = false
|
@State var showControllerTest = false
|
||||||
#endif
|
#endif
|
||||||
@@ -284,19 +282,6 @@ struct SettingsView: View {
|
|||||||
("4K @ 60", "3840x2160x60"),
|
("4K @ 60", "3840x2160x60"),
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Stage-2 vs stage-3 present pacing (see SettingsView+Sections' presenterSection for the
|
|
||||||
/// rationale); the freeze-prone stage-1 diagnostic only ships in DEBUG builds.
|
|
||||||
private static var presenterOptions: [(label: String, tag: String)] {
|
|
||||||
var options: [(label: String, tag: String)] = [
|
|
||||||
("Stage 2 (default)", "stage2"),
|
|
||||||
("Stage 3 (experimental)", "stage3"),
|
|
||||||
]
|
|
||||||
#if DEBUG
|
|
||||||
options.append(("Stage 1 (debug)", "stage1"))
|
|
||||||
#endif
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
||||||
private var modeTag: Binding<String> {
|
private var modeTag: Binding<String> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { "\(width)x\(height)x\(hz)" },
|
get: { "\(width)x\(height)x\(hz)" },
|
||||||
@@ -313,6 +298,12 @@ struct SettingsView: View {
|
|||||||
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
Binding(get: { hdrEnabled ? "on" : "off" }, set: { hdrEnabled = $0 == "on" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The gamepad-UI switch as an on/off row (same shape as HDR above) — the escape hatch back
|
||||||
|
/// to this focus-engine home for someone who prefers it with a controller connected.
|
||||||
|
private var gamepadUIEnabledTag: Binding<String> {
|
||||||
|
Binding(get: { gamepadUIEnabled ? "on" : "off" }, set: { gamepadUIEnabled = $0 == "on" })
|
||||||
|
}
|
||||||
|
|
||||||
private var tvBody: some View {
|
private var tvBody: some View {
|
||||||
let currentTag = "\(width)x\(height)x\(hz)"
|
let currentTag = "\(width)x\(height)x\(hz)"
|
||||||
let bounds = UIScreen.main.nativeBounds
|
let bounds = UIScreen.main.nativeBounds
|
||||||
@@ -338,7 +329,7 @@ struct SettingsView: View {
|
|||||||
selection: $audioChannels)
|
selection: $audioChannels)
|
||||||
if bitrateKbps > 1_000_000 {
|
if bitrateKbps > 1_000_000 {
|
||||||
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
Label(Self.gigabitWarning, systemImage: "exclamationmark.triangle.fill")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption)) // TV-legible caption size
|
||||||
.foregroundStyle(.orange)
|
.foregroundStyle(.orange)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
@@ -347,7 +338,7 @@ struct SettingsView: View {
|
|||||||
selection: $compositor)
|
selection: $compositor)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Presenter",
|
title: "Presenter",
|
||||||
options: Self.presenterOptions,
|
options: SettingsOptions.presenters,
|
||||||
selection: $presenter)
|
selection: $presenter)
|
||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "10-bit HDR",
|
title: "10-bit HDR",
|
||||||
@@ -355,7 +346,7 @@ struct SettingsView: View {
|
|||||||
Text("The host creates a virtual output at exactly this mode — native "
|
Text("The host creates a virtual output at exactly this mode — native "
|
||||||
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
+ "resolution, no scaling. \(Self.bitrateFooter) A specific compositor "
|
||||||
+ "is honored only if available on the host.")
|
+ "is honored only if available on the host.")
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
@@ -375,8 +366,11 @@ struct SettingsView: View {
|
|||||||
TVSelectionRow(
|
TVSelectionRow(
|
||||||
title: "Controller type", options: SettingsOptions.padTypes,
|
title: "Controller type", options: SettingsOptions.padTypes,
|
||||||
selection: $gamepadType)
|
selection: $gamepadType)
|
||||||
|
TVSelectionRow(
|
||||||
|
title: "Gamepad-optimized browsing",
|
||||||
|
options: [("On", "on"), ("Off", "off")], selection: gamepadUIEnabledTag)
|
||||||
Text(Self.controllersFooter)
|
Text(Self.controllersFooter)
|
||||||
.font(.geist(12, relativeTo: .caption))
|
.font(.geist(20, relativeTo: .caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
|
|||||||
@@ -82,20 +82,36 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
|
|||||||
var interactive = false
|
var interactive = false
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 26, macOS 26, tvOS 26, *) {
|
#if os(tvOS)
|
||||||
|
// ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these
|
||||||
|
// surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the
|
||||||
|
// Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights
|
||||||
|
// the 10-foot platform). The tint rides an overlay so the focused row keeps its wash.
|
||||||
|
content.background {
|
||||||
|
shape.fill(.ultraThinMaterial)
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
.overlay {
|
||||||
|
if let tint { shape.fill(tint) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
if #available(iOS 26, macOS 26, *) {
|
||||||
content.glassEffect(glass, in: shape)
|
content.glassEffect(glass, in: shape)
|
||||||
} else {
|
} else {
|
||||||
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 26, macOS 26, tvOS 26, *)
|
#if !os(tvOS)
|
||||||
|
@available(iOS 26, macOS 26, *)
|
||||||
private var glass: Glass {
|
private var glass: Glass {
|
||||||
var g: Glass = .regular
|
var g: Glass = .regular
|
||||||
if let tint { g = g.tint(tint) }
|
if let tint { g = g.tint(tint) }
|
||||||
if interactive { g = g.interactive() }
|
if interactive { g = g.interactive() }
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ struct PairSheet: View {
|
|||||||
+ "(http://<host>:3000 → Pairing). "
|
+ "(http://<host>:3000 → Pairing). "
|
||||||
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
+ "Pairing verifies both sides at once — no fingerprint comparison "
|
||||||
+ "needed.")
|
+ "needed.")
|
||||||
.font(.geist(16, relativeTo: .callout))
|
.font(.geist(22, relativeTo: .callout)) // TV-legible (system callout is ~25 there)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
TVFieldRow(
|
TVFieldRow(
|
||||||
@@ -59,7 +59,7 @@ struct PairSheet: View {
|
|||||||
) { editing = .clientName }
|
) { editing = .clientName }
|
||||||
if let errorText {
|
if let errorText {
|
||||||
Text(errorText)
|
Text(errorText)
|
||||||
.font(.geist(16, relativeTo: .callout))
|
.font(.geist(22, relativeTo: .callout))
|
||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
HStack(spacing: 32) {
|
HStack(spacing: 32) {
|
||||||
|
|||||||
@@ -263,19 +263,24 @@ public final class SessionAudio {
|
|||||||
defer { drainDone.signal() }
|
defer { drainDone.signal() }
|
||||||
// Decode happens IN-CORE (libopus multistream) — AudioToolbox's Opus path is
|
// 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.
|
// stereo-only — and is handed back as interleaved f32 PCM in wire channel order.
|
||||||
while !flag.isStopped {
|
// Per-iteration autorelease pool: no runloop on this thread (see Stage2Pipeline).
|
||||||
|
var alive = true
|
||||||
|
while alive, !flag.isStopped {
|
||||||
|
alive = autoreleasepool { () -> Bool in
|
||||||
let pcm: PunktfunkConnection.AudioPCM?
|
let pcm: PunktfunkConnection.AudioPCM?
|
||||||
do {
|
do {
|
||||||
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
pcm = try connection.nextAudioPcm(timeoutMs: 100)
|
||||||
} catch {
|
} catch {
|
||||||
break // session closed
|
return false // session closed
|
||||||
}
|
}
|
||||||
guard let pcm, pcm.frameCount > 0 else { continue }
|
guard let pcm, pcm.frameCount > 0 else { return true }
|
||||||
pcm.samples.withUnsafeBufferPointer { p in
|
pcm.samples.withUnsafeBufferPointer { p in
|
||||||
if let base = p.baseAddress {
|
if let base = p.baseAddress {
|
||||||
ring.write(base, count: pcm.frameCount * pcm.channels)
|
ring.write(base, count: pcm.frameCount * pcm.channels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
thread.name = "punktfunk-audio"
|
thread.name = "punktfunk-audio"
|
||||||
|
|||||||
@@ -48,6 +48,23 @@ public final class GamepadCapture {
|
|||||||
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
/// Motion forwarding floor: ≥ 4 ms between samples (≈ 250 Hz, the DualSense's own rate).
|
||||||
private static let motionIntervalNs: UInt64 = 4_000_000
|
private static let motionIntervalNs: UInt64 = 4_000_000
|
||||||
|
|
||||||
|
/// The cross-client controller escape chord (pf-client-core's `ESCAPE_CHORD`):
|
||||||
|
/// L1+R1+Start+Select held together — four simultaneous buttons no game uses, so normal
|
||||||
|
/// play can't trip it. Held for `disconnectHold` it ends the session via
|
||||||
|
/// `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 =
|
||||||
|
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
|
||||||
|
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
|
||||||
|
/// stream with a controller: B/Menu presses are deliberately swallowed during a session so
|
||||||
|
/// gameplay can't end it (see ContentView's tvOS session branch).
|
||||||
|
public var onDisconnectRequest: (() -> Void)?
|
||||||
|
|
||||||
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
public init(connection: PunktfunkConnection, manager: GamepadManager) {
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
@@ -165,6 +182,7 @@ public final class GamepadCapture {
|
|||||||
private func sync(_ g: GCExtendedGamepad) {
|
private func sync(_ g: GCExtendedGamepad) {
|
||||||
guard !suspended else { return }
|
guard !suspended else { return }
|
||||||
let newButtons = Self.buttonMask(g)
|
let newButtons = Self.buttonMask(g)
|
||||||
|
updateEscapeChord(newButtons)
|
||||||
let changed = newButtons ^ buttons
|
let changed = newButtons ^ buttons
|
||||||
if changed != 0 {
|
if changed != 0 {
|
||||||
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
for bit in GamepadWire.allButtons where changed & bit != 0 {
|
||||||
@@ -297,7 +315,26 @@ public final class GamepadCapture {
|
|||||||
|
|
||||||
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
/// Unwind everything held on the wire: button-ups, neutral axes, lifted fingers. The
|
||||||
/// host's virtual pad returns to rest instead of running with the last state.
|
/// host's virtual pad returns to rest instead of running with the last state.
|
||||||
|
/// Arm the disconnect timer when the full chord lands, disarm the moment any of the four
|
||||||
|
/// releases. Events only arrive on state CHANGES, so a held chord needs the timer — the
|
||||||
|
/// handler won't fire again until something moves.
|
||||||
|
private func updateEscapeChord(_ newButtons: UInt32) {
|
||||||
|
let held = newButtons & Self.escapeChord == Self.escapeChord
|
||||||
|
if held, chordTimer == nil {
|
||||||
|
let timer = Timer(timeInterval: Self.disconnectHold, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in self?.onDisconnectRequest?() }
|
||||||
|
}
|
||||||
|
RunLoop.main.add(timer, forMode: .common)
|
||||||
|
chordTimer = timer
|
||||||
|
} else if !held, chordTimer != nil {
|
||||||
|
chordTimer?.invalidate()
|
||||||
|
chordTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func releaseAll() {
|
private func releaseAll() {
|
||||||
|
chordTimer?.invalidate()
|
||||||
|
chordTimer = nil
|
||||||
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
for bit in GamepadWire.allButtons where buttons & bit != 0 {
|
||||||
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
connection.send(.gamepadButton(bit, down: false, pad: 0))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ public final class GamepadFeedback {
|
|||||||
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
// session — a DualSense or a DualShock 4 (lightbar only). Block briefly on it there and
|
||||||
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
// let rumble own the wait elsewhere; on an Xbox session it stays nonblocking.
|
||||||
let thread = Thread { [connection, flag, drainDone, weak self] in
|
let thread = Thread { [connection, flag, drainDone, weak self] in
|
||||||
while !flag.isStopped {
|
// Per-iteration autorelease pool: no runloop on this thread, and the haptics/HID
|
||||||
|
// rendering below autoreleases ObjC temporaries. `false` = session over.
|
||||||
|
var alive = true
|
||||||
|
while alive, !flag.isStopped {
|
||||||
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
// Poll the feedback planes NON-BLOCKING. A blocking poll (timeoutMs > 0) holds
|
||||||
// the connection's shared feedback lock for its whole wait; the video pump drains
|
// the connection's shared feedback lock for its whole wait; the video pump drains
|
||||||
@@ -106,12 +110,14 @@ public final class GamepadFeedback {
|
|||||||
self?.render(ev)
|
self?.render(ev)
|
||||||
burst += 1
|
burst += 1
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
break // .closed (or fatal) — the session is over
|
return false // .closed (or fatal) — the session is over
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
// ~8 ms poll cadence (≈125 Hz), slept OUTSIDE the feedback lock — low rumble/HID
|
||||||
// latency without holding the lock the HDR-meta drain needs.
|
// latency without holding the lock the HDR-meta drain needs.
|
||||||
if !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
if alive, !flag.isStopped { Thread.sleep(forTimeInterval: 0.008) }
|
||||||
}
|
}
|
||||||
drainDone.signal()
|
drainDone.signal()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// The Siri Remote as a pointing device during a tvOS streaming session — the remote's touch
|
||||||
|
// surface drives the HOST cursor (relative deltas, like a laptop trackpad), a surface press
|
||||||
|
// clicks (left button), and Play/Pause right-clicks. It also owns the remote's DELIBERATE
|
||||||
|
// session exit: hold Back/Menu ≥ `disconnectHold`. A short Back press does nothing — the
|
||||||
|
// UIKit menu press it also generates is swallowed by ContentView's session branch, so neither
|
||||||
|
// a trackpad fumble nor a game-controller B press can end the session (the pad's exit is the
|
||||||
|
// L1+R1+Start+Select chord in GamepadCapture).
|
||||||
|
//
|
||||||
|
// The remote is read through GameController as a GCMicroGamepad with
|
||||||
|
// `reportsAbsoluteDpadValues = true`: the dpad axes then report the finger's ABSOLUTE position
|
||||||
|
// on the surface (±1, +y up) while touched, and snap to exactly (0, 0) on lift. Successive
|
||||||
|
// positions are differenced into relative mouse deltas; the exact-zero snap is treated as a
|
||||||
|
// lift (a real touch at the mathematical centre is measure-zero, and one dropped delta there
|
||||||
|
// is imperceptible). Handlers (not a poll) — the same in-session delivery GamepadCapture
|
||||||
|
// relies on.
|
||||||
|
//
|
||||||
|
// Lifecycle mirrors GamepadCapture: started by SessionModel when streaming begins (never
|
||||||
|
// during the trust prompt), stopped on disconnect; held buttons are released on stop so the
|
||||||
|
// host never keeps a stuck click.
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
import Foundation
|
||||||
|
import GameController
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SiriRemotePointer {
|
||||||
|
private let connection: PunktfunkConnection
|
||||||
|
private var observers: [NSObjectProtocol] = []
|
||||||
|
private var bound: GCController?
|
||||||
|
/// Finger position (±1 axes) at the last dpad callback while touched; nil = lifted.
|
||||||
|
private var lastTouch: (x: Float, y: Float)?
|
||||||
|
/// Wire buttons currently held (1 = left, 3 = right) — released on stop/unbind.
|
||||||
|
private var heldButtons: Set<UInt32> = []
|
||||||
|
/// When Back/Menu went down; a release after `disconnectHold` fires the exit.
|
||||||
|
private var menuDownAt: Date?
|
||||||
|
|
||||||
|
/// Hold Back/Menu at least this long (then release) to end the session. Shorter than the
|
||||||
|
/// controller chord's 1.5 s — the remote has no way to trip this during gameplay.
|
||||||
|
private static let disconnectHold: TimeInterval = 1.0
|
||||||
|
/// A full edge-to-edge swipe moves the host cursor about this many pixels. The surface is
|
||||||
|
/// small; two comfortable swipes should cross a 1080p desktop.
|
||||||
|
private static let pointerScale: Float = 1100
|
||||||
|
/// Largest single-callback finger travel accepted as real motion (surface units; the axes
|
||||||
|
/// span ±1, so 0.4 ≈ a fifth of the pad). On RELEASE the hardware slides the reported
|
||||||
|
/// position back to (0, 0) through intermediate callbacks — naive differencing turns that
|
||||||
|
/// tail into reverse deltas that RETRACE the whole swipe, so the cursor springs back to its
|
||||||
|
/// anchor and the pointer feels absolute. Real finger motion arrives as many small steps
|
||||||
|
/// (even a fast flick stays well under this per callback); the release tail arrives as one
|
||||||
|
/// or two huge jumps — discard those (the anchor still follows, so nothing accumulates).
|
||||||
|
private static let maxStep: Float = 0.4
|
||||||
|
|
||||||
|
/// Fired ON MAIN after Back/Menu was held ≥ `disconnectHold` and released.
|
||||||
|
public var onDisconnectRequest: (() -> Void)?
|
||||||
|
|
||||||
|
public init(connection: PunktfunkConnection) {
|
||||||
|
self.connection = connection
|
||||||
|
}
|
||||||
|
|
||||||
|
public func start() {
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .GCControllerDidConnect, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.rebind() }
|
||||||
|
})
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .GCControllerDidDisconnect, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { self?.rebind() }
|
||||||
|
})
|
||||||
|
rebind()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
observers.forEach(NotificationCenter.default.removeObserver(_:))
|
||||||
|
observers.removeAll()
|
||||||
|
bind(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Siri Remote is the non-extended controller carrying a microGamepad — a full gamepad
|
||||||
|
/// (which also EXPOSES a microGamepad view of itself) must never be captured here, its
|
||||||
|
/// buttons belong to GamepadCapture.
|
||||||
|
private func rebind() {
|
||||||
|
let remote = GCController.controllers().first {
|
||||||
|
$0.extendedGamepad == nil && $0.microGamepad != nil
|
||||||
|
}
|
||||||
|
bind(remote)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(_ controller: GCController?) {
|
||||||
|
guard controller !== bound else { return }
|
||||||
|
if let old = bound?.microGamepad {
|
||||||
|
old.dpad.valueChangedHandler = nil
|
||||||
|
old.buttonA.pressedChangedHandler = nil
|
||||||
|
old.buttonX.pressedChangedHandler = nil
|
||||||
|
old.buttonMenu.pressedChangedHandler = nil
|
||||||
|
}
|
||||||
|
releaseHeld()
|
||||||
|
lastTouch = nil
|
||||||
|
menuDownAt = nil
|
||||||
|
bound = controller
|
||||||
|
guard let micro = controller?.microGamepad else { return }
|
||||||
|
|
||||||
|
// Absolute finger position instead of the emulated dpad — the raw surface is what a
|
||||||
|
// trackpad needs. Rotation stays off: the remote's natural grip is the coordinate frame.
|
||||||
|
micro.reportsAbsoluteDpadValues = true
|
||||||
|
micro.allowsRotation = false
|
||||||
|
|
||||||
|
micro.dpad.valueChangedHandler = { [weak self] _, x, y in
|
||||||
|
MainActor.assumeIsolated { self?.touchMoved(x: x, y: y) }
|
||||||
|
}
|
||||||
|
// Surface click = left button; Play/Pause = right (the remote's only spare face button).
|
||||||
|
micro.buttonA.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
|
MainActor.assumeIsolated { self?.setButton(1, down: pressed) }
|
||||||
|
}
|
||||||
|
micro.buttonX.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
|
MainActor.assumeIsolated { self?.setButton(3, down: pressed) }
|
||||||
|
}
|
||||||
|
micro.buttonMenu.pressedChangedHandler = { [weak self] _, _, pressed in
|
||||||
|
MainActor.assumeIsolated { self?.menuChanged(pressed: pressed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func touchMoved(x: Float, y: Float) {
|
||||||
|
// Exact (0, 0) is the lift snap — drop the anchor so the next touch starts a fresh
|
||||||
|
// gesture instead of a jump-delta from the old position.
|
||||||
|
guard x != 0 || y != 0 else {
|
||||||
|
lastTouch = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer { lastTouch = (x, y) }
|
||||||
|
guard let last = lastTouch else { return } // first contact anchors, moves nothing
|
||||||
|
let stepX = x - last.x
|
||||||
|
let stepY = y - last.y
|
||||||
|
// The release tail (and any tracking glitch) shows up as a single impossible jump —
|
||||||
|
// see `maxStep`. Skip the emission; the deferred anchor update above still follows the
|
||||||
|
// reported position, so the gesture cleanly re-anchors instead of retracing.
|
||||||
|
guard abs(stepX) < Self.maxStep, abs(stepY) < Self.maxStep else { return }
|
||||||
|
let dx = stepX * Self.pointerScale / 2 // axes span ±1 → full swipe = 2.0
|
||||||
|
let dy = -stepY * Self.pointerScale / 2 // GC +y is up; mouse +y is down
|
||||||
|
let ix = Int32(dx.rounded())
|
||||||
|
let iy = Int32(dy.rounded())
|
||||||
|
guard ix != 0 || iy != 0 else { return }
|
||||||
|
connection.send(.mouseMove(dx: ix, dy: iy))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setButton(_ button: UInt32, down: Bool) {
|
||||||
|
if down { heldButtons.insert(button) } else { heldButtons.remove(button) }
|
||||||
|
connection.send(.mouseButton(button, down: down))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func menuChanged(pressed: Bool) {
|
||||||
|
if pressed {
|
||||||
|
menuDownAt = Date()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let heldFor = menuDownAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
menuDownAt = nil
|
||||||
|
if heldFor >= Self.disconnectHold {
|
||||||
|
onDisconnectRequest?()
|
||||||
|
}
|
||||||
|
// A short press is deliberately nothing: the accompanying UIKit menu press is swallowed
|
||||||
|
// in ContentView, and forwarding it as a host key would make trackpad fumbles type.
|
||||||
|
}
|
||||||
|
|
||||||
|
private func releaseHeld() {
|
||||||
|
for button in heldButtons {
|
||||||
|
connection.send(.mouseButton(button, down: false))
|
||||||
|
}
|
||||||
|
heldButtons.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -13,14 +13,14 @@ public enum Licenses {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
/// punktfunk's own license — MIT OR Apache-2.0, at your option.
|
/// Punktfunk's own license — MIT OR Apache-2.0, at your option.
|
||||||
public static var appLicense: String {
|
public static var appLicense: String {
|
||||||
let mit = resource("LICENSE-MIT")
|
let mit = resource("LICENSE-MIT")
|
||||||
let apache = resource("LICENSE-APACHE")
|
let apache = resource("LICENSE-APACHE")
|
||||||
if mit.isEmpty && apache.isEmpty {
|
if mit.isEmpty && apache.isEmpty {
|
||||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
return "Punktfunk is licensed under MIT OR Apache-2.0, at your option."
|
||||||
}
|
}
|
||||||
return "punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
return "Punktfunk is licensed under MIT OR Apache-2.0, at your option.\n\n"
|
||||||
+ "================================ MIT ================================\n\n"
|
+ "================================ MIT ================================\n\n"
|
||||||
+ mit
|
+ mit
|
||||||
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
+ "\n\n============================== Apache-2.0 ==============================\n\n"
|
||||||
@@ -51,11 +51,27 @@ public enum Licenses {
|
|||||||
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
/// Acknowledgements screen renders these chunks in a `LazyVStack` (only on-screen chunks lay
|
||||||
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
/// out, and no chunk is tall enough to clip). Split at line boundaries and joined with "\n";
|
||||||
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
/// the inter-chunk break is the `LazyVStack` row boundary, so no text is lost. Computed once.
|
||||||
public static let thirdPartyNoticesChunks: [String] = {
|
public static let thirdPartyNoticesChunks: [String] = chunked(thirdPartyNotices)
|
||||||
let lines = thirdPartyNotices.split(separator: "\n", omittingEmptySubsequences: false)
|
|
||||||
let chunkSize = 200
|
/// Lines per chunk: tvOS reads much smaller chunks — focus is how tvOS scrolls, and each
|
||||||
return stride(from: 0, to: lines.count, by: chunkSize).map { start in
|
/// chunk is one focus stop, so a 200-line chunk (~5 screens tall there) would skip most of
|
||||||
lines[start..<min(start + chunkSize, lines.count)].joined(separator: "\n")
|
/// itself per step; ~24 lines ≈ two thirds of a screen reads like a page turn. Elsewhere the
|
||||||
|
/// only constraint is the text-render height limit, so chunks stay big.
|
||||||
|
private static var chunkLines: Int {
|
||||||
|
#if os(tvOS)
|
||||||
|
24
|
||||||
|
#else
|
||||||
|
200
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `text` split at line boundaries into render/focus-sized chunks (joined with "\n"; the
|
||||||
|
/// inter-chunk break is the caller's stack-row boundary, so no text is lost). tvOS pages
|
||||||
|
/// focus through these — every license wall on the Acknowledgements screen renders this way.
|
||||||
|
public static func chunked(_ text: String) -> [String] {
|
||||||
|
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||||
|
return stride(from: 0, to: lines.count, by: chunkLines).map { start in
|
||||||
|
lines[start..<min(start + chunkLines, lines.count)].joined(separator: "\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// The Y′CbCr→RGB conversion as three shader rows, ported from pf-client-core's `csc_rows`
|
||||||
|
// (crates/pf-client-core/src/video.rs) — the ONE coefficient implementation every punktfunk
|
||||||
|
// presenter derives its CSC from. Keep the two in LOCKSTEP: both carry the same unit tests
|
||||||
|
// (CscRowsTests.swift ↔ the Rust `csc_rows` tests), and a coefficient change lands in both or
|
||||||
|
// neither.
|
||||||
|
//
|
||||||
|
// Why this exists: the stage-2 Metal shaders used to hardcode BT.709 (SDR) / BT.2020 (HDR)
|
||||||
|
// matrices, silently ignoring the stream's signaled matrix. A Linux host's RGB-input NVENC paths
|
||||||
|
// signal BT.601 limited (NVENC's fixed internal RGB→YUV conversion; ffmpeg force-writes that
|
||||||
|
// VUI), so those streams rendered with the wrong coefficients — a constant hue error. The rows
|
||||||
|
// are now computed per frame from the decoded buffer's actual signaling (VideoToolbox propagates
|
||||||
|
// the HEVC VUI / AV1 colour config onto the CVPixelBuffer's attachments) and handed to the
|
||||||
|
// fragment shaders as bytes.
|
||||||
|
|
||||||
|
import CoreVideo
|
||||||
|
import simd
|
||||||
|
|
||||||
|
/// The fragment shaders' CSC constant block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w`.
|
||||||
|
/// Layout matches the Metal-side `struct CscUniform { float4 r0; float4 r1; float4 r2; }`
|
||||||
|
/// (three 16-byte-aligned float4s, stride 48) — passed via `setFragmentBytes`.
|
||||||
|
public struct CscUniform: Equatable, Sendable {
|
||||||
|
public var r0: SIMD4<Float>
|
||||||
|
public var r1: SIMD4<Float>
|
||||||
|
public var r2: SIMD4<Float>
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CscRows {
|
||||||
|
/// A decoded frame's Y′CbCr signaling: the H.273 matrix code (1 = BT.709, 5/6 = BT.601,
|
||||||
|
/// 9/10 = BT.2020; 2 = unspecified → the BT.709 SDR default, mirroring `ColorDesc`) and
|
||||||
|
/// whether the samples are full range.
|
||||||
|
public struct Signal: Equatable, Sendable {
|
||||||
|
public var matrix: UInt8
|
||||||
|
public var fullRange: Bool
|
||||||
|
|
||||||
|
public init(matrix: UInt8, fullRange: Bool) {
|
||||||
|
self.matrix = matrix
|
||||||
|
self.fullRange = fullRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a decoded buffer's signaling: the matrix from the `CVImageBuffer` attachment
|
||||||
|
/// (VideoToolbox propagates the bitstream's colour description there), the range from the
|
||||||
|
/// pixel format itself (the video- vs full-range biplanar siblings), so a full-range stream
|
||||||
|
/// expands correctly no matter which sibling VideoToolbox delivered.
|
||||||
|
public static func signal(of buffer: CVPixelBuffer) -> Signal {
|
||||||
|
var matrix: UInt8 = 2 // unspecified → BT.709 default in rows()
|
||||||
|
if let att = CVBufferCopyAttachment(buffer, kCVImageBufferYCbCrMatrixKey, nil),
|
||||||
|
CFGetTypeID(att) == CFStringGetTypeID() {
|
||||||
|
let s = unsafeDowncast(att, to: CFString.self)
|
||||||
|
if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_709_2) {
|
||||||
|
matrix = 1
|
||||||
|
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_601_4) {
|
||||||
|
matrix = 5
|
||||||
|
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_SMPTE_240M_1995) {
|
||||||
|
matrix = 7
|
||||||
|
} else if CFEqual(s, kCVImageBufferYCbCrMatrix_ITU_R_2020) {
|
||||||
|
matrix = 9
|
||||||
|
} else {
|
||||||
|
// CICP codes CoreMedia has no named constant for arrive as the literal string
|
||||||
|
// "YCbCrMatrix#<code>" — the suffix IS the H.273 code. BT.470BG (5) takes this
|
||||||
|
// form (proven by the 601 golden fixture), and BT.470BG is exactly what a Linux
|
||||||
|
// host's RGB-input NVENC signals, so missing it re-creates the hue bug the
|
||||||
|
// per-frame signaling exists to fix.
|
||||||
|
let str = s as String
|
||||||
|
if str.hasPrefix("YCbCrMatrix#"), let code = UInt8(str.dropFirst(12)) {
|
||||||
|
matrix = code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pf = CVPixelBufferGetPixelFormatType(buffer)
|
||||||
|
let fullRange = pf == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
|
||||||
|
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||||
|
|| pf == kCVPixelFormatType_444YpCbCr8BiPlanarFullRange
|
||||||
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||||
|
return Signal(matrix: matrix, fullRange: fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the three rows — bit-depth exact. `depth` picks the limited-range code points
|
||||||
|
/// (8-bit: 16/235/240 over 255; 10-bit: 64/940/960 over 1023 — NOT the same normalized
|
||||||
|
/// values, the difference is ~half a code). `msbPacked` folds in the P010/x444 packing
|
||||||
|
/// factor: 10 significant bits live in the MSBs of 16, so an `.r16Unorm` sample reads
|
||||||
|
/// `code·64/65535` — multiplying by `65535/65472` recovers exact `code/1023` (this replaces
|
||||||
|
/// the shaders' old documented ~0.1% approximation).
|
||||||
|
public static func rows(_ signal: Signal, depth: Int, msbPacked: Bool) -> CscUniform {
|
||||||
|
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||||
|
// BT.709 SDR default (mirrors the Rust side's dispatch).
|
||||||
|
let (kr, kb): (Double, Double)
|
||||||
|
switch signal.matrix {
|
||||||
|
case 5, 6: (kr, kb) = (0.299, 0.114)
|
||||||
|
case 9, 10: (kr, kb) = (0.2627, 0.0593)
|
||||||
|
default: (kr, kb) = (0.2126, 0.0722)
|
||||||
|
}
|
||||||
|
let kg = 1.0 - kr - kb
|
||||||
|
let max = Double((1 << depth) - 1) // 255 / 1023
|
||||||
|
let step = Double(1 << (depth - 8)) // code points per 8-bit step: 1 / 4
|
||||||
|
let pack = msbPacked ? 65535.0 / 65472.0 : 1.0
|
||||||
|
let (sy, oy, sc): (Double, Double, Double)
|
||||||
|
if signal.fullRange {
|
||||||
|
(sy, oy, sc) = (pack, 0.0, pack)
|
||||||
|
} else {
|
||||||
|
(sy, oy, sc) = (
|
||||||
|
pack * max / (219.0 * step),
|
||||||
|
-(16.0 * step) / max,
|
||||||
|
pack * max / (224.0 * step)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
||||||
|
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
||||||
|
// factor to land on the same scale.
|
||||||
|
let off = [oy / pack, -0.5 / pack, -0.5 / pack]
|
||||||
|
let m: [[Double]] = [
|
||||||
|
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
||||||
|
[
|
||||||
|
sy,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
],
|
||||||
|
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
||||||
|
]
|
||||||
|
func row(_ r: Int) -> SIMD4<Float> {
|
||||||
|
let w = (0..<3).reduce(0.0) { $0 + m[r][$1] * off[$1] }
|
||||||
|
return SIMD4(Float(m[r][0]), Float(m[r][1]), Float(m[r][2]), Float(w))
|
||||||
|
}
|
||||||
|
return CscUniform(r0: row(0), r1: row(1), r2: row(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,17 +28,39 @@ private let presenterLog = Logger(subsystem: "io.unom.punktfunk", category: "pre
|
|||||||
/// dimmer. Matches the host's standard PQ reference white.
|
/// dimmer. Matches the host's standard PQ reference white.
|
||||||
private let hdrReferenceWhiteNits: Float = 203.0
|
private let hdrReferenceWhiteNits: Float = 203.0
|
||||||
|
|
||||||
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and BT.709 SDR
|
/// PUNKTFUNK_SDR_COLORSPACE=srgb — A/B hatch for the SDR layer's colour tag. Today the SDR layer
|
||||||
/// and BT.2020-PQ HDR Y′CbCr→RGB fragment shaders. uv.y is flipped (1 - p.y) so the top-left-origin
|
/// ships with `colorspace = nil`, which on macOS means NO colour matching: the BT.709/sRGB-encoded
|
||||||
/// texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded R′G′B′ as-is — the
|
/// stream is displayed with the panel's native primaries — mild oversaturation on every P3 Mac.
|
||||||
/// CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system compositor the samples
|
/// `srgb` tags the layer so CoreAnimation colour-matches it into the panel's gamut (the strictly
|
||||||
/// are PQ and how to tone-map them (no EOTF here, matching the host's BT.2020 PQ emission).
|
/// correct rendering). Kept OFF by default until the on-glass A/B confirms it (the nil path is the
|
||||||
|
/// long-proven look, and some users may prefer the vivid rendition); flip the default once verified.
|
||||||
|
private let sdrColorspaceOverride: CGColorSpace? = {
|
||||||
|
guard ProcessInfo.processInfo.environment["PUNKTFUNK_SDR_COLORSPACE"] == "srgb" else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CGColorSpace(name: CGColorSpace.sRGB)
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Runtime-compiled (no metallib build step needed in SwiftPM): a fullscreen triangle and Y′CbCr→RGB
|
||||||
|
/// fragment shaders whose conversion arrives as three constant rows computed per frame on the CPU
|
||||||
|
/// (`CscRows` — the Swift port of pf-client-core's `csc_rows`, from the decoded buffer's actual
|
||||||
|
/// signaling). One set of coefficients honors BT.601/709/2020 × full/limited × 8/10-bit instead of
|
||||||
|
/// the old hardcoded BT.709/BT.2020 matrices — a BT.601-signaled stream (a Linux host's RGB-input
|
||||||
|
/// NVENC) used to render with BT.709 coefficients, a constant hue error. uv.y is flipped (1 - p.y)
|
||||||
|
/// so the top-left-origin texture presents upright (NDC y is up). The HDR shader outputs PQ-encoded
|
||||||
|
/// R′G′B′ as-is — the CAMetalLayer's `itur_2100_PQ` colour space + `edrMetadata` tell the system
|
||||||
|
/// compositor the samples are PQ and how to tone-map them (no EOTF here, matching the host's
|
||||||
|
/// BT.2020 PQ emission).
|
||||||
private let shaderSource = """
|
private let shaderSource = """
|
||||||
#include <metal_stdlib>
|
#include <metal_stdlib>
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
|
|
||||||
struct VOut { float4 pos [[position]]; float2 uv; };
|
struct VOut { float4 pos [[position]]; float2 uv; };
|
||||||
|
|
||||||
|
// The CPU-computed CSC rows (CscRows.swift, layout-matched): rgb[i] = dot(ri.xyz, yuv) + ri.w.
|
||||||
|
// Range expansion, the matrix, and the 10-bit MSB-packing factor are all folded in.
|
||||||
|
struct CscUniform { float4 r0; float4 r1; float4 r2; };
|
||||||
|
|
||||||
vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
vertex VOut pf_vtx(uint vid [[vertex_id]]) {
|
||||||
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
|
float2 p = float2(float((vid << 1) & 2), float(vid & 2));
|
||||||
VOut o;
|
VOut o;
|
||||||
@@ -94,43 +116,80 @@ float2 chromaUV(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv)
|
|||||||
return uv;
|
return uv;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SDR: 8-bit NV12 / 4:4:4 (BT.709, limited/video range) → full-range RGB. Chroma is sampled at the
|
// The shared sample + row-multiply: Y′CbCr (bicubic luma, siting-corrected bilinear chroma) →
|
||||||
// (siting-corrected) luma UV, so a full-size 4:4:4 chroma plane needs no shader change vs 4:2:0.
|
// RGB via the per-frame rows. A full-size 4:4:4 chroma plane needs no change vs 4:2:0 (the siting
|
||||||
fragment float4 pf_frag(VOut in [[stage_in]],
|
// offset self-disables). What the result MEANS depends on the stream: an SDR frame's rows yield
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
// gamma-encoded RGB, an HDR frame's rows yield PQ-encoded R′G′B′ — the fragment variants below
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
// differ only in what they do next.
|
||||||
|
float3 sampleRgb(texture2d<float> lumaTex, texture2d<float> chromaTex, float2 uv,
|
||||||
|
constant CscUniform& csc) {
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
||||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
float3 yuv = float3(catmullRomLuma(lumaTex, s, uv),
|
||||||
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
|
chromaTex.sample(s, chromaUV(lumaTex, chromaTex, uv)).rg);
|
||||||
// BT.709, 8-bit limited (video) range → full-range RGB.
|
return saturate(float3(dot(csc.r0.xyz, yuv) + csc.r0.w,
|
||||||
y = (y - 16.0/255.0) * (255.0/219.0);
|
dot(csc.r1.xyz, yuv) + csc.r1.w,
|
||||||
float u = (c.x - 128.0/255.0) * (255.0/224.0);
|
dot(csc.r2.xyz, yuv) + csc.r2.w));
|
||||||
float v = (c.y - 128.0/255.0) * (255.0/224.0);
|
|
||||||
float r = y + 1.5748 * v;
|
|
||||||
float g = y - 0.1873 * u - 0.4681 * v;
|
|
||||||
float b = y + 1.8556 * u;
|
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HDR: 10-bit P010 / 4:4:4 (BT.2020, limited range), Y′CbCr that is PQ-encoded. We apply the BT.2020
|
// SDR: 8-bit NV12 / 4:4:4 → full-range RGB, transfer left baked (shown as-is, the proven SDR
|
||||||
// matrix to get PQ-encoded R′G′B′ and output it as-is — the CAMetalLayer's itur_2100_PQ colour space
|
// layer config).
|
||||||
// + edrMetadata tell the compositor the samples are PQ, so it does the PQ→display tone-map. No EOTF
|
fragment float4 pf_frag(VOut in [[stage_in]],
|
||||||
// here. P010/x444 store the 10-bit code in the high bits of each 16-bit sample, so an .r16Unorm sample
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
// reads ~code/1023 (the /1024 vs /1023 error is < 0.1%).
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
|
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HDR: 10-bit P010 / 4:4:4 (BT.2020, PQ-encoded Y′CbCr) → full-range PQ R′G′B′, output as-is —
|
||||||
|
// the CAMetalLayer's itur_2100_PQ colour space + edrMetadata tell the compositor the samples are
|
||||||
|
// PQ, so it does the PQ→display tone-map. No EOTF here. The rows fold in the exact 10-bit
|
||||||
|
// MSB-packing factor (the old hardcoded shader carried a documented ~0.1% /1024-vs-/1023 error).
|
||||||
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
fragment float4 pf_frag_hdr(VOut in [[stage_in]],
|
||||||
texture2d<float> lumaTex [[texture(0)]],
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
texture2d<float> chromaTex [[texture(1)]]) {
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
constexpr sampler s(filter::linear, address::clamp_to_edge);
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
float y = catmullRomLuma(lumaTex, s, in.uv);
|
return float4(sampleRgb(lumaTex, chromaTex, in.uv, csc), 1.0);
|
||||||
float2 c = chromaTex.sample(s, chromaUV(lumaTex, chromaTex, in.uv)).rg;
|
}
|
||||||
// BT.2020 10-bit limited (video) range → full-range PQ R′G′B′.
|
|
||||||
y = (y - 64.0/1023.0) * (1023.0/876.0);
|
// HDR on tvOS when the display is composited WITHOUT HDR headroom (SDR output mode, or the user
|
||||||
float u = (c.x - 512.0/1023.0) * (1023.0/896.0);
|
// disabled Match Dynamic Range): no Metal EDR API exists there (CAEDRMetadata /
|
||||||
float v = (c.y - 512.0/1023.0) * (1023.0/896.0);
|
// wantsExtendedDynamicRangeContent are API_UNAVAILABLE(tvos)), and a bare PQ colour-space tag
|
||||||
float r = y + 1.4746 * v;
|
// composites UNtone-mapped — the CAMetalLayer header says so outright — which showed as a badly
|
||||||
float g = y - 0.16455 * u - 0.57135 * v;
|
// overblown picture on Apple TV. So this variant finishes the job in-shader: PQ EOTF → linear
|
||||||
float b = y + 1.8814 * u;
|
// light, 203-nit reference white (BT.2408) anchored at display white, extended-Reinhard highlight
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
// rolloff with a 1000-nit knee, BT.2020→BT.709 primaries, BT.709 OETF — into the proven SDR layer
|
||||||
|
// config. The 10-bit BT.2020 stream keeps its full decode depth; only the final presentation is
|
||||||
|
// display-referred SDR. (When the display IS in an HDR mode — requested per session via
|
||||||
|
// AVDisplayManager, see StreamViewIOS — tvOS presents pf_frag_hdr's PQ passthrough instead:
|
||||||
|
// in a genuine HDR10 output, PQ passthrough is the correct emission and the TV tone-maps.)
|
||||||
|
fragment float4 pf_frag_hdr_tv(VOut in [[stage_in]],
|
||||||
|
texture2d<float> lumaTex [[texture(0)]],
|
||||||
|
texture2d<float> chromaTex [[texture(1)]],
|
||||||
|
constant CscUniform& csc [[buffer(0)]]) {
|
||||||
|
// Y′CbCr → full-range PQ R′G′B′ via the per-frame rows (as pf_frag_hdr).
|
||||||
|
float3 pq = sampleRgb(lumaTex, chromaTex, in.uv, csc);
|
||||||
|
// ST 2084 EOTF: PQ code value → linear light, 1.0 = 10,000 nits.
|
||||||
|
const float m1 = 2610.0/16384.0;
|
||||||
|
const float m2 = 78.84375;
|
||||||
|
const float c1 = 3424.0/4096.0;
|
||||||
|
const float c2 = 18.8515625;
|
||||||
|
const float c3 = 18.6875;
|
||||||
|
float3 p = pow(pq, 1.0/m2);
|
||||||
|
float3 lin = pow(max(p - c1, 0.0) / (c2 - c3 * p), 1.0/m1);
|
||||||
|
// Scene-referred with diffuse white at 1.0 (the same 203-nit anchor the EDR path uses).
|
||||||
|
float3 t = lin * (10000.0/203.0);
|
||||||
|
// BT.2020 → BT.709 primaries while still linear; negatives are out-of-gamut, floor them.
|
||||||
|
float3 t709 = float3(
|
||||||
|
dot(t, float3( 1.6605, -0.5876, -0.0728)),
|
||||||
|
dot(t, float3(-0.1246, 1.1329, -0.0083)),
|
||||||
|
dot(t, float3(-0.0182, -0.1006, 1.1187)));
|
||||||
|
t709 = max(t709, 0.0);
|
||||||
|
// Extended Reinhard: 1.0 stays put, the 1000-nit knee lands at display white, above rolls off.
|
||||||
|
const float w = 1000.0/203.0;
|
||||||
|
float3 mapped = saturate(t709 * (1.0 + t709 / (w * w)) / (1.0 + t709));
|
||||||
|
// BT.709 OETF — the same encoding the SDR stream arrives in, so both paths present alike.
|
||||||
|
float3 e = select(1.099 * pow(mapped, 0.45) - 0.099, 4.5 * mapped, mapped < 0.018);
|
||||||
|
return float4(e, 1.0);
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -144,12 +203,19 @@ public final class MetalVideoPresenter {
|
|||||||
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
/// frame in `render`; the layer is reconfigured to match when the session flips (HDR toggle).
|
||||||
private let pipelineSDR: MTLRenderPipelineState
|
private let pipelineSDR: MTLRenderPipelineState
|
||||||
private let pipelineHDR: MTLRenderPipelineState
|
private let pipelineHDR: MTLRenderPipelineState
|
||||||
|
/// tvOS only: the in-shader PQ→SDR tone-map fallback (pf_frag_hdr_tv → bgra8), used whenever
|
||||||
|
/// the display is composited without HDR headroom — see `setDisplayHeadroom`. nil elsewhere.
|
||||||
|
private let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
private var textureCache: CVMetalTextureCache?
|
private var textureCache: CVMetalTextureCache?
|
||||||
|
|
||||||
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
/// Current layer configuration — switched in `configure(hdr:)` when a frame's HDR-ness differs.
|
||||||
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
/// Render-thread confined once the pipeline runs (Stage2Pipeline.start's one pre-thread
|
||||||
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
/// `configure` call is ordered before the thread starts, so it doesn't race).
|
||||||
private var hdrActive = false
|
private var hdrActive = false
|
||||||
|
/// tvOS only: whether HDR frames currently present as PQ PASSTHROUGH (display has HDR headroom
|
||||||
|
/// — its own tone-map applies) vs the in-shader tone-map fallback. Render-thread confined;
|
||||||
|
/// derived from the staged display headroom at the top of every `render`.
|
||||||
|
private var hdrPassthroughActive = false
|
||||||
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
/// Last HDR mastering grade received via `setHdrMeta` (the host's 0xCE). Cached so a mid-session
|
||||||
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
/// SDR→HDR flip's `configureColor` re-applies the real grade instead of clobbering it back to the
|
||||||
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
/// bare reference-white anchor (an out-of-order race otherwise: `setHdrMeta` and the flip both write
|
||||||
@@ -163,6 +229,11 @@ public final class MetalVideoPresenter {
|
|||||||
private let stagingLock = NSLock()
|
private let stagingLock = NSLock()
|
||||||
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
private var pendingHdrMeta: PunktfunkConnection.HdrMeta?
|
||||||
private var drawableTarget: CGSize = .zero
|
private var drawableTarget: CGSize = .zero
|
||||||
|
/// tvOS: the display's current EDR headroom (UIScreen.currentEDRHeadroom), pushed from the
|
||||||
|
/// main thread (SessionPresenter.layout + the mode-switch observers). > 1 ⇒ the display is
|
||||||
|
/// composited with HDR headroom, so HDR frames present as PQ passthrough; otherwise the
|
||||||
|
/// in-shader tone-map keeps the picture from blowing out. 1 (the default) is the safe start.
|
||||||
|
private var stagedDisplayHeadroom: CGFloat = 1.0
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
/// Last logged "decoded→drawable" signature, so the diagnostic logs only on a size/HDR change.
|
||||||
@@ -177,6 +248,7 @@ public final class MetalVideoPresenter {
|
|||||||
else { return nil }
|
else { return nil }
|
||||||
let pipelineSDR: MTLRenderPipelineState
|
let pipelineSDR: MTLRenderPipelineState
|
||||||
let pipelineHDR: MTLRenderPipelineState
|
let pipelineHDR: MTLRenderPipelineState
|
||||||
|
let pipelineHDRToneMap: MTLRenderPipelineState?
|
||||||
do {
|
do {
|
||||||
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
let library = try device.makeLibrary(source: shaderSource, options: nil)
|
||||||
let vtx = library.makeFunction(name: "pf_vtx")
|
let vtx = library.makeFunction(name: "pf_vtx")
|
||||||
@@ -188,8 +260,20 @@ public final class MetalVideoPresenter {
|
|||||||
let hdr = MTLRenderPipelineDescriptor()
|
let hdr = MTLRenderPipelineDescriptor()
|
||||||
hdr.vertexFunction = vtx
|
hdr.vertexFunction = vtx
|
||||||
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
|
hdr.fragmentFunction = library.makeFunction(name: "pf_frag_hdr")
|
||||||
hdr.colorAttachments[0].pixelFormat = .rgba16Float // EDR-capable
|
hdr.colorAttachments[0].pixelFormat = .rgba16Float // PQ passthrough
|
||||||
pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr)
|
pipelineHDR = try device.makeRenderPipelineState(descriptor: hdr)
|
||||||
|
#if os(tvOS)
|
||||||
|
// tvOS carries BOTH HDR pipelines: PQ passthrough when the display is composited
|
||||||
|
// with HDR headroom, the in-shader tone-map (→ the 8-bit SDR config) when it isn't.
|
||||||
|
// See setDisplayHeadroom / configureColor.
|
||||||
|
let tm = MTLRenderPipelineDescriptor()
|
||||||
|
tm.vertexFunction = vtx
|
||||||
|
tm.fragmentFunction = library.makeFunction(name: "pf_frag_hdr_tv")
|
||||||
|
tm.colorAttachments[0].pixelFormat = .bgra8Unorm
|
||||||
|
pipelineHDRToneMap = try device.makeRenderPipelineState(descriptor: tm)
|
||||||
|
#else
|
||||||
|
pipelineHDRToneMap = nil
|
||||||
|
#endif
|
||||||
} catch {
|
} catch {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -229,17 +313,19 @@ public final class MetalVideoPresenter {
|
|||||||
|
|
||||||
return MetalVideoPresenter(
|
return MetalVideoPresenter(
|
||||||
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
device: device, queue: queue, pipelineSDR: pipelineSDR, pipelineHDR: pipelineHDR,
|
||||||
textureCache: textureCache, layer: layer)
|
pipelineHDRToneMap: pipelineHDRToneMap, textureCache: textureCache, layer: layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(
|
private init(
|
||||||
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
device: MTLDevice, queue: MTLCommandQueue, pipelineSDR: MTLRenderPipelineState,
|
||||||
pipelineHDR: MTLRenderPipelineState, textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
pipelineHDR: MTLRenderPipelineState, pipelineHDRToneMap: MTLRenderPipelineState?,
|
||||||
|
textureCache: CVMetalTextureCache, layer: CAMetalLayer
|
||||||
) {
|
) {
|
||||||
self.device = device
|
self.device = device
|
||||||
self.queue = queue
|
self.queue = queue
|
||||||
self.pipelineSDR = pipelineSDR
|
self.pipelineSDR = pipelineSDR
|
||||||
self.pipelineHDR = pipelineHDR
|
self.pipelineHDR = pipelineHDR
|
||||||
|
self.pipelineHDRToneMap = pipelineHDRToneMap
|
||||||
self.textureCache = textureCache
|
self.textureCache = textureCache
|
||||||
self.layer = layer
|
self.layer = layer
|
||||||
}
|
}
|
||||||
@@ -251,30 +337,68 @@ public final class MetalVideoPresenter {
|
|||||||
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
/// an rgba16Float drawable + BT.2020 PQ colour space + EDR with a 203-nit reference-white anchor;
|
||||||
/// SDR uses the plain 8-bit sRGB path.
|
/// SDR uses the plain 8-bit sRGB path.
|
||||||
public func configure(hdr: Bool) {
|
public func configure(hdr: Bool) {
|
||||||
|
#if os(tvOS)
|
||||||
|
// Reconfigure on an HDR flip AND on a passthrough↔tone-map flip: the display's headroom
|
||||||
|
// changes when the AVDisplayManager mode switch (requested at session start) completes —
|
||||||
|
// typically a second or two into the session.
|
||||||
|
stagingLock.lock()
|
||||||
|
let passthrough = stagedDisplayHeadroom > 1.0
|
||||||
|
stagingLock.unlock()
|
||||||
|
guard hdr != hdrActive || (hdr && passthrough != hdrPassthroughActive) else { return }
|
||||||
|
hdrActive = hdr
|
||||||
|
hdrPassthroughActive = passthrough
|
||||||
|
#else
|
||||||
guard hdr != hdrActive else { return }
|
guard hdr != hdrActive else { return }
|
||||||
hdrActive = hdr
|
hdrActive = hdr
|
||||||
|
#endif
|
||||||
configureColor(hdr: hdr)
|
configureColor(hdr: hdr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// tvOS: park the display's current EDR headroom (a MAIN-thread `UIScreen` read — pushed by
|
||||||
|
/// SessionPresenter.layout and the stream view's mode-switch observers). > 1 flips HDR frames
|
||||||
|
/// to PQ passthrough (the display's own tone-map applies); ≤ 1 keeps the in-shader tone-map.
|
||||||
|
/// Applied by the render thread on the next frame, like every other staged value here.
|
||||||
|
public func setDisplayHeadroom(_ headroom: CGFloat) {
|
||||||
|
stagingLock.lock()
|
||||||
|
stagedDisplayHeadroom = headroom
|
||||||
|
stagingLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
|
/// Set the layer's pixel format + colour config for SDR or HDR. MAIN THREAD ONLY. EDR is requested
|
||||||
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
|
/// on macOS + iOS (the old `#if os(macOS)` guard left iOS EDR half-engaged). tvOS has NO EDR API
|
||||||
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there), so
|
/// (`wantsExtendedDynamicRangeContent`/`edrMetadata`/`CAEDRMetadata` are all unavailable there) —
|
||||||
/// it gets the PQ pixel format + colour space only — the tvOS compositor tone-maps from those.
|
/// and a bare PQ colour-space tag composites UNtone-mapped (the "overblown HDR" Apple TV report),
|
||||||
|
/// so tvOS instead tone-maps PQ→SDR in the shader (pf_frag_hdr_tv) and keeps the SDR layer config.
|
||||||
private func configureColor(hdr: Bool) {
|
private func configureColor(hdr: Bool) {
|
||||||
if hdr {
|
if hdr {
|
||||||
|
#if os(tvOS)
|
||||||
|
if hdrPassthroughActive {
|
||||||
|
// Display composited WITH HDR headroom (the session's AVDisplayManager request
|
||||||
|
// landed): emit PQ passthrough — in a real HDR10 output that's the correct
|
||||||
|
// emission, and the TV applies its own tone-map.
|
||||||
|
layer.pixelFormat = .rgba16Float
|
||||||
|
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||||
|
} else {
|
||||||
|
// SDR-composited display: PQ would render untone-mapped (blown out) — the
|
||||||
|
// pf_frag_hdr_tv shader tone-maps to SDR instead.
|
||||||
|
layer.pixelFormat = .bgra8Unorm
|
||||||
|
layer.colorspace = nil
|
||||||
|
}
|
||||||
|
#else
|
||||||
layer.pixelFormat = .rgba16Float
|
layer.pixelFormat = .rgba16Float
|
||||||
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
layer.colorspace = CGColorSpace(name: CGColorSpace.itur_2100_PQ)
|
||||||
#if !os(tvOS)
|
|
||||||
layer.wantsExtendedDynamicRangeContent = true
|
layer.wantsExtendedDynamicRangeContent = true
|
||||||
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
// Anchor reference white. Re-apply the real grade if one already arrived (0xCE before the
|
||||||
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
// flip); otherwise the bare 203-nit anchor. Without this anchor the PQ signal is too bright.
|
||||||
layer.edrMetadata = makeEDR(lastHdrMeta)
|
layer.edrMetadata = makeEDR(lastHdrMeta)
|
||||||
#endif
|
#endif
|
||||||
} else {
|
} else {
|
||||||
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable; a nil colorspace tags it device/sRGB
|
// SDR: gamma-encoded BT.709 [0,1] in an 8-bit drawable. Default: nil colorspace = NO
|
||||||
// (the proven SDR path — never showed the "too bright" issue, which was HDR-only).
|
// colour matching on macOS (the panel's native primaries — the long-proven look,
|
||||||
|
// slightly oversaturated on P3 panels); PUNKTFUNK_SDR_COLORSPACE=srgb tags the layer
|
||||||
|
// for correct colour matching instead (A/B pending — see sdrColorspaceOverride).
|
||||||
layer.pixelFormat = .bgra8Unorm
|
layer.pixelFormat = .bgra8Unorm
|
||||||
layer.colorspace = nil
|
layer.colorspace = sdrColorspaceOverride
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
layer.wantsExtendedDynamicRangeContent = false
|
layer.wantsExtendedDynamicRangeContent = false
|
||||||
layer.edrMetadata = nil
|
layer.edrMetadata = nil
|
||||||
@@ -360,6 +484,11 @@ public final class MetalVideoPresenter {
|
|||||||
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
|| pf == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarVideoRange
|
||||||
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
|| pf == kCVPixelFormatType_444YpCbCr10BiPlanarFullRange
|
||||||
|
// The frame's Y′CbCr→RGB rows, from its ACTUAL signaling (buffer attachments + pixel
|
||||||
|
// format) — a BT.601-signaled stream gets 601 coefficients, full-range gets full-range
|
||||||
|
// expansion; recomputed per frame because the host can flip colour in-band (SDR↔HDR).
|
||||||
|
var csc = CscRows.rows(
|
||||||
|
CscRows.signal(of: pixelBuffer), depth: tenBit ? 10 : 8, msbPacked: tenBit)
|
||||||
guard let textureCache,
|
guard let textureCache,
|
||||||
let luma = makeTexture(
|
let luma = makeTexture(
|
||||||
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
pixelBuffer, plane: 0, format: tenBit ? .r16Unorm : .r8Unorm, cache: textureCache),
|
||||||
@@ -395,9 +524,17 @@ public final class MetalVideoPresenter {
|
|||||||
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: pass) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
// HDR splits by the display's headroom (kept in step with the layer by `configure` above):
|
||||||
|
// PQ passthrough into an HDR-composited display, the tone-map shader otherwise.
|
||||||
|
let hdrPipeline = hdrPassthroughActive ? pipelineHDR : (pipelineHDRToneMap ?? pipelineHDR)
|
||||||
|
encoder.setRenderPipelineState(hdrActive ? hdrPipeline : pipelineSDR)
|
||||||
|
#else
|
||||||
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
encoder.setRenderPipelineState(hdrActive ? pipelineHDR : pipelineSDR)
|
||||||
|
#endif
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(luma), index: 0)
|
||||||
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
encoder.setFragmentTexture(CVMetalTextureGetTexture(chroma), index: 1)
|
||||||
|
encoder.setFragmentBytes(&csc, length: MemoryLayout<CscUniform>.stride, index: 0)
|
||||||
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
|
||||||
encoder.endEncoding()
|
encoder.endEncoding()
|
||||||
if let onPresented {
|
if let onPresented {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import QuartzCore
|
import QuartzCore
|
||||||
|
#if os(tvOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
|
/// Weak-target wrapper for CADisplayLink. The link retains its target, so targeting a view or
|
||||||
/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a
|
/// presenter directly makes a `owner → link → owner` cycle that only `invalidate()` breaks — if a
|
||||||
@@ -34,16 +37,31 @@ enum PresenterChoice: Equatable {
|
|||||||
|
|
||||||
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
/// Resolve from the `PUNKTFUNK_PRESENTER` env override (A/B without touching settings) first,
|
||||||
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
/// then the persisted `DefaultsKey.presenter` setting; anything unknown (or an empty env var)
|
||||||
/// falls back to stage-2. `allowStage1` is false in release builds, where a leftover DEBUG
|
/// falls back to the platform default. `allowStage1` is false in release builds, where a
|
||||||
/// "stage1" value silently maps to stage-2 rather than reviving the freeze-prone fallback.
|
/// leftover DEBUG "stage1" value silently maps to the default rather than reviving the
|
||||||
|
/// freeze-prone fallback.
|
||||||
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
static func resolve(setting: String?, env: String?, allowStage1: Bool) -> PresenterChoice {
|
||||||
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
let raw = env.flatMap { $0.isEmpty ? nil : $0 } ?? setting
|
||||||
switch raw {
|
switch raw {
|
||||||
case "stage1": return allowStage1 ? .stage1 : .stage2
|
case "stage1": return allowStage1 ? .stage1 : platformDefault
|
||||||
|
case "stage2": return .stage2
|
||||||
case "stage3": return .stage3
|
case "stage3": return .stage3
|
||||||
default: return .stage2
|
default: return platformDefault
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// tvOS defaults to GLASS pacing: an Apple TV is the sticky-FIFO worst case by construction —
|
||||||
|
/// a fixed 60 Hz panel fed a 60 fps stream, where arrival pacing pins the layer's image queue
|
||||||
|
/// at ~3 drawables and every frame rides ~50 ms of queue (the measured display stage there).
|
||||||
|
/// The Settings picker can still force stage-2 for an A/B. Everything else keeps stage-2 (the
|
||||||
|
/// proven default; ProMotion/desktop panels out-tick the stream often enough to drain).
|
||||||
|
static var platformDefault: PresenterChoice {
|
||||||
|
#if os(tvOS)
|
||||||
|
.stage3
|
||||||
|
#else
|
||||||
|
.stage2
|
||||||
|
#endif
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SessionPresenter {
|
final class SessionPresenter {
|
||||||
@@ -179,6 +197,13 @@ final class SessionPresenter {
|
|||||||
stage2?.setDrawableTarget(CGSize(
|
stage2?.setDrawableTarget(CGSize(
|
||||||
width: (fit.width * contentsScale).rounded(),
|
width: (fit.width * contentsScale).rounded(),
|
||||||
height: (fit.height * contentsScale).rounded()))
|
height: (fit.height * contentsScale).rounded()))
|
||||||
|
#if os(tvOS)
|
||||||
|
// Push the display's live EDR headroom alongside: > 1 means the TV is composited in an
|
||||||
|
// HDR mode (the session's AVDisplayManager request landed — see StreamViewIOS), and HDR
|
||||||
|
// frames flip to PQ passthrough. The stream view also re-layouts on mode-switch/screen-
|
||||||
|
// mode notifications, so a mid-session switch reaches here without a bounds change.
|
||||||
|
stage2?.setDisplayHeadroom(UIScreen.main.currentEDRHeadroom)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
/// Stop the active pump/pipeline (≤ one poll timeout; stage-2 joins its pump) and detach the
|
||||||
|
|||||||
@@ -358,7 +358,12 @@ public final class Stage2Pipeline {
|
|||||||
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
// decode 4:4:4 at the negotiated resolution (the HW probe clears the common case but not a
|
||||||
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
// resolution-ceiling miss). End cleanly instead of looping on a black screen.
|
||||||
var decodeFailRun = 0
|
var decodeFailRun = 0
|
||||||
while !token.isStopped {
|
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||||
|
// autoreleased VT/CM temporaries would otherwise accumulate until session end.
|
||||||
|
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||||
|
var alive = true
|
||||||
|
while alive, !token.isStopped {
|
||||||
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
// Loss recovery (the primary path). The reassembler drops unrecoverable AUs and the
|
||||||
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
// decoder conceals the reference-missing deltas — often WITHOUT an error callback —
|
||||||
@@ -378,13 +383,13 @@ public final class Stage2Pipeline {
|
|||||||
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
if let meta = try? connection.nextHdrMeta(timeoutMs: 0) {
|
||||||
presenter.setHdrMeta(meta)
|
presenter.setHdrMeta(meta)
|
||||||
}
|
}
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
if let f = connection.videoCodec.formatDescription(fromKeyframe: au.data) {
|
||||||
format = f // refreshed on every IDR (mode changes included)
|
format = f // refreshed on every IDR (mode changes included)
|
||||||
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
awaitingIDR = false // a fresh IDR re-anchored decode — recovery complete
|
||||||
}
|
}
|
||||||
guard let f = format, !token.isStopped else { continue }
|
guard let f = format, !token.isStopped else { return true }
|
||||||
if decoder.decode(au: au, format: f) {
|
if decoder.decode(au: au, format: f) {
|
||||||
decodeFailRun = 0
|
decodeFailRun = 0
|
||||||
} else {
|
} else {
|
||||||
@@ -397,12 +402,14 @@ public final class Stage2Pipeline {
|
|||||||
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
// recovers within a GOP) ⇒ 4:4:4 isn't decodable here; end the session.
|
||||||
if connection.isChroma444, decodeFailRun >= 180 {
|
if connection.isChroma444, decodeFailRun >= 180 {
|
||||||
if !token.isStopped { onSessionEnd?() }
|
if !token.isStopped { onSessionEnd?() }
|
||||||
break
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
if !token.isStopped { onSessionEnd?() }
|
if !token.isStopped { onSessionEnd?() }
|
||||||
break // session closed
|
return false // session closed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -435,10 +442,14 @@ public final class Stage2Pipeline {
|
|||||||
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
let gate: PresentGate? = pacing == .glass ? PresentGate() : nil
|
||||||
let renderThread = Thread {
|
let renderThread = Thread {
|
||||||
defer { renderStopped.signal() }
|
defer { renderStopped.signal() }
|
||||||
while !token.isStopped {
|
// Every iteration drains its own autorelease pool (`return` = the old `continue`):
|
||||||
|
// this thread has no runloop, and `nextDrawable()` AUTORELEASES each CAMetalDrawable —
|
||||||
|
// without a per-iteration pool every presented frame's drawable object (plus its
|
||||||
|
// texture-descriptor/array retinue, ~2 MB/min at 120 fps) piles up until session end.
|
||||||
|
while !token.isStopped { autoreleasepool {
|
||||||
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
if renderSignal.wait(timeout: .now() + .milliseconds(100)) == .timedOut {
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
// Stage-3: while a present is in flight, don't take from the ring at all — frames
|
||||||
// keep coalescing there (newest wins, the intended drop point) and the presented
|
// keep coalescing there (newest wins, the intended drop point) and the presented
|
||||||
@@ -447,13 +458,13 @@ public final class Stage2Pipeline {
|
|||||||
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
if let gate, !gate.tryAcquire(now: CACurrentMediaTime()) {
|
||||||
debugStats?.gatedWake()
|
debugStats?.gatedWake()
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
guard !token.isStopped, let frame = ring.take() else {
|
guard !token.isStopped, let frame = ring.take() else {
|
||||||
gate?.release() // armed but nothing to render — don't hold the gate stale
|
gate?.release() // armed but nothing to render — don't hold the gate stale
|
||||||
debugStats?.emptyWake()
|
debugStats?.emptyWake()
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
// V-Sync ON: flip on the next predicted vsync (< one period out, stale link ⇒
|
||||||
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
// immediate — see VsyncClock). OFF: flip as soon as the GPU finishes.
|
||||||
@@ -488,7 +499,7 @@ public final class Stage2Pipeline {
|
|||||||
ring.putBack(frame)
|
ring.putBack(frame)
|
||||||
}
|
}
|
||||||
debugStats?.flushIfDue(ring: ring, gate: gate)
|
debugStats?.flushIfDue(ring: ring, gate: gate)
|
||||||
}
|
} }
|
||||||
}
|
}
|
||||||
renderThread.name = "punktfunk-stage2-render"
|
renderThread.name = "punktfunk-stage2-render"
|
||||||
renderThread.qualityOfService = .userInteractive
|
renderThread.qualityOfService = .userInteractive
|
||||||
@@ -512,6 +523,13 @@ public final class Stage2Pipeline {
|
|||||||
presenter.setDrawableTarget(size)
|
presenter.setDrawableTarget(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Forward the display's current EDR headroom to the presenter (MAIN thread — a `UIScreen`
|
||||||
|
/// read). tvOS flips HDR presentation between PQ passthrough and the in-shader tone-map on
|
||||||
|
/// it; see `MetalVideoPresenter.setDisplayHeadroom`.
|
||||||
|
public func setDisplayHeadroom(_ headroom: CGFloat) {
|
||||||
|
presenter.setDisplayHeadroom(headroom)
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN
|
/// Stop the pump + render thread (≤ one poll timeout each) and drop the decode session. MAIN
|
||||||
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
/// THREAD; idempotent. Does not close the connection. A restart needs a fresh Stage2Pipeline
|
||||||
/// (the stop is permanent).
|
/// (the stop is permanent).
|
||||||
|
|||||||
@@ -47,7 +47,12 @@ final class StreamPump {
|
|||||||
var awaitingIDR = false
|
var awaitingIDR = false
|
||||||
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
var awaitingSince = Date.distantPast // when the current recovery began (for the resume log)
|
||||||
var wasFailed = false
|
var wasFailed = false
|
||||||
while !token.isStopped {
|
// Every iteration drains its own autorelease pool: this thread has no runloop, so
|
||||||
|
// autoreleased CM/layer temporaries would otherwise accumulate until session end.
|
||||||
|
// `false` = session over — exit the loop (the closure can't `break` across itself).
|
||||||
|
var alive = true
|
||||||
|
while alive, !token.isStopped {
|
||||||
|
alive = autoreleasepool { () -> Bool in
|
||||||
do {
|
do {
|
||||||
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
// Loss recovery (the primary path). Under the host's infinite GOP the only
|
||||||
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
// recovery keyframe is one we request. The reassembler drops unrecoverable AUs
|
||||||
@@ -69,7 +74,7 @@ final class StreamPump {
|
|||||||
}
|
}
|
||||||
if awaitingIDR { recovery.request() }
|
if awaitingIDR { recovery.request() }
|
||||||
|
|
||||||
guard let au = try connection.nextAU(timeoutMs: 100) else { continue }
|
guard let au = try connection.nextAU(timeoutMs: 100) else { return true }
|
||||||
onFrame?(au)
|
onFrame?(au)
|
||||||
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
let idrFormat = connection.videoCodec.formatDescription(fromKeyframe: au.data)
|
||||||
if let f = idrFormat {
|
if let f = idrFormat {
|
||||||
@@ -97,13 +102,15 @@ final class StreamPump {
|
|||||||
guard let f = format,
|
guard let f = format,
|
||||||
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
let sample = connection.videoCodec.sampleBuffer(au: au, format: f),
|
||||||
!token.isStopped // don't enqueue a stale frame after a restart
|
!token.isStopped // don't enqueue a stale frame after a restart
|
||||||
else { continue }
|
else { return true }
|
||||||
layer.enqueue(sample)
|
layer.enqueue(sample)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
if !token.isStopped {
|
if !token.isStopped {
|
||||||
onSessionEnd?()
|
onSessionEnd?()
|
||||||
}
|
}
|
||||||
break // session closed
|
return false // session closed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import PunktfunkCore
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import os
|
import os
|
||||||
|
#if os(tvOS)
|
||||||
|
import AVKit // AVDisplayManager — the per-session display-mode (HDR10/refresh) request
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the
|
/// Same diagnostic switch as InputCapture (PUNKTFUNK_INPUT_DEBUG=1): on iOS we log the
|
||||||
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
/// resolved pointer-lock state each time capture engages, so the user can see whether the
|
||||||
@@ -108,7 +111,20 @@ public struct StreamView: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class StreamViewController: UIViewController {
|
#if os(tvOS)
|
||||||
|
/// tvOS: a GCEventViewController with `controllerUserInteractionEnabled = false` routes game-
|
||||||
|
/// controller (and Siri Remote) input EXCLUSIVELY to the GameController framework while the
|
||||||
|
/// stream is up. Without it a pad's B/Menu press doubles as a UIKit menu press — which ended
|
||||||
|
/// the session (or suspended the whole app) from ordinary gameplay; a SwiftUI
|
||||||
|
/// `.onExitCommand {}` swallow proved unreliable with nothing focusable on screen. Every
|
||||||
|
/// in-session exit is GC-level by design: the pad's escape chord (GamepadCapture) and the
|
||||||
|
/// remote's hold-Back (SiriRemotePointer).
|
||||||
|
public typealias StreamViewControllerBase = GCEventViewController
|
||||||
|
#else
|
||||||
|
public typealias StreamViewControllerBase = UIViewController
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public final class StreamViewController: StreamViewControllerBase {
|
||||||
public private(set) var connection: PunktfunkConnection?
|
public private(set) var connection: PunktfunkConnection?
|
||||||
private var observers: [NSObjectProtocol] = []
|
private var observers: [NSObjectProtocol] = []
|
||||||
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
/// Record the unified latency stages (end-to-end / decode / display) when the stage-2
|
||||||
@@ -119,6 +135,11 @@ public final class StreamViewController: UIViewController {
|
|||||||
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
|
||||||
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
/// stage-1 StreamPump → displayLayer path as the Metal-unavailable / DEBUG fallback.
|
||||||
private let presenter = SessionPresenter()
|
private let presenter = SessionPresenter()
|
||||||
|
#if os(tvOS)
|
||||||
|
/// The window's display manager the session's mode request was set on — held weakly so
|
||||||
|
/// stop() can clear the request even after the view has left the window.
|
||||||
|
private weak var sessionDisplayManager: AVDisplayManager?
|
||||||
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
private var inputCapture: InputCapture?
|
private var inputCapture: InputCapture?
|
||||||
fileprivate var captured = false
|
fileprivate var captured = false
|
||||||
@@ -157,6 +178,12 @@ public final class StreamViewController: UIViewController {
|
|||||||
|
|
||||||
public override func loadView() {
|
public override func loadView() {
|
||||||
view = StreamLayerUIView()
|
view = StreamLayerUIView()
|
||||||
|
#if os(tvOS)
|
||||||
|
// Kill the pad/remote → UIKit press path at the source for the whole session (see the
|
||||||
|
// GCEventViewController typealias above). GC delivery is untouched: GamepadCapture
|
||||||
|
// forwards the pad, SiriRemotePointer drives the pointer and owns the remote exit.
|
||||||
|
controllerUserInteractionEnabled = false
|
||||||
|
#endif
|
||||||
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
// Re-size the stage-2 drawable if the display scale changes without a bounds change (e.g.
|
||||||
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
// moving to an external display at a different scale) — the iOS analogue of macOS's
|
||||||
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
// viewDidChangeBackingProperties relayout. The handler takes the VC as its argument, so it
|
||||||
@@ -230,6 +257,18 @@ public final class StreamViewController: UIViewController {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// The GCEventViewController's interaction flag applies to the deepest such controller
|
||||||
|
// CONTAINING THE FIRST RESPONDER — inside SwiftUI's hosting-controller sandwich that is not
|
||||||
|
// guaranteed to be us unless we anchor the responder chain here explicitly.
|
||||||
|
public override var canBecomeFirstResponder: Bool { true }
|
||||||
|
|
||||||
|
public override func viewDidAppear(_ animated: Bool) {
|
||||||
|
super.viewDidAppear(animated)
|
||||||
|
becomeFirstResponder()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
func start(
|
func start(
|
||||||
connection: PunktfunkConnection,
|
connection: PunktfunkConnection,
|
||||||
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
onFrame: (@Sendable (AccessUnit) -> Void)?,
|
||||||
@@ -342,6 +381,19 @@ public final class StreamViewController: UIViewController {
|
|||||||
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
setCaptured(true) // entering a session is the deliberate "capture me" moment
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// The TV's mode switch (requested in applyDisplayCriteriaIfNeeded) completes
|
||||||
|
// asynchronously, and a dynamic-range-only switch doesn't re-layout by itself —
|
||||||
|
// re-layout on the switch/mode notifications so the presenter sees the new EDR
|
||||||
|
// headroom immediately (layout pushes UIScreen.currentEDRHeadroom down).
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVDisplayManagerModeSwitchEnd, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in self?.layoutMetalLayer() })
|
||||||
|
observers.append(NotificationCenter.default.addObserver(
|
||||||
|
forName: UIScreen.modeDidChangeNotification, object: nil, queue: .main
|
||||||
|
) { [weak self] _ in self?.layoutMetalLayer() })
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
@@ -360,6 +412,12 @@ public final class StreamViewController: UIViewController {
|
|||||||
streamView.onScroll = nil
|
streamView.onScroll = nil
|
||||||
streamView.currentHostMode = nil
|
streamView.currentHostMode = nil
|
||||||
#endif
|
#endif
|
||||||
|
#if os(tvOS)
|
||||||
|
// Return the TV to the user's preferred mode — the home screen must not stay in the
|
||||||
|
// session's HDR10/refresh mode.
|
||||||
|
sessionDisplayManager?.preferredDisplayCriteria = nil
|
||||||
|
sessionDisplayManager = nil
|
||||||
|
#endif
|
||||||
presenter.stop()
|
presenter.stop()
|
||||||
connection = nil
|
connection = nil
|
||||||
}
|
}
|
||||||
@@ -367,8 +425,50 @@ public final class StreamViewController: UIViewController {
|
|||||||
public override func viewDidLayoutSubviews() {
|
public override func viewDidLayoutSubviews() {
|
||||||
super.viewDidLayoutSubviews()
|
super.viewDidLayoutSubviews()
|
||||||
layoutMetalLayer()
|
layoutMetalLayer()
|
||||||
|
#if os(tvOS)
|
||||||
|
applyDisplayCriteriaIfNeeded()
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
/// Ask the TV for a display mode matching the session — HDR10 at the stream's refresh rate —
|
||||||
|
/// via AVDisplayManager, the tvOS mechanism custom renderers use for HDR output (AVFoundation
|
||||||
|
/// playback layers do this implicitly). Honored only when the user allows matching (tvOS
|
||||||
|
/// Settings → Video and Audio → Match Content); the presenter reads the RESULT off UIScreen's
|
||||||
|
/// EDR headroom (pushed in SessionPresenter.layout) and keeps the in-shader tone-map whenever
|
||||||
|
/// the switch never lands, so an SDR-composited display can't show blown-out PQ either way.
|
||||||
|
/// Applied once per session, as soon as the window and the negotiated mode both exist; the
|
||||||
|
/// stop() teardown clears it.
|
||||||
|
private func applyDisplayCriteriaIfNeeded() {
|
||||||
|
guard let manager = view.window?.avDisplayManager, let connection,
|
||||||
|
manager.preferredDisplayCriteria == nil,
|
||||||
|
UserDefaults.standard.object(forKey: DefaultsKey.hdrEnabled) as? Bool ?? true
|
||||||
|
else { return }
|
||||||
|
let mode = connection.currentMode()
|
||||||
|
guard mode.width > 0, mode.height > 0, mode.refreshHz > 0 else { return }
|
||||||
|
// A synthetic HDR10-HEVC format description carrying the negotiated mode — what the
|
||||||
|
// stream decodes to. AVDisplayCriteria(refreshRate:formatDescription:) matches the
|
||||||
|
// display to it (tvOS 17+, our deployment floor).
|
||||||
|
let ext: [CFString: Any] = [
|
||||||
|
kCMFormatDescriptionExtension_ColorPrimaries:
|
||||||
|
kCMFormatDescriptionColorPrimaries_ITU_R_2020,
|
||||||
|
kCMFormatDescriptionExtension_TransferFunction:
|
||||||
|
kCMFormatDescriptionTransferFunction_SMPTE_ST_2084_PQ,
|
||||||
|
kCMFormatDescriptionExtension_YCbCrMatrix:
|
||||||
|
kCMFormatDescriptionYCbCrMatrix_ITU_R_2020,
|
||||||
|
]
|
||||||
|
var desc: CMFormatDescription?
|
||||||
|
CMVideoFormatDescriptionCreate(
|
||||||
|
allocator: kCFAllocatorDefault, codecType: kCMVideoCodecType_HEVC,
|
||||||
|
width: Int32(mode.width), height: Int32(mode.height),
|
||||||
|
extensions: ext as CFDictionary, formatDescriptionOut: &desc)
|
||||||
|
guard let desc else { return }
|
||||||
|
manager.preferredDisplayCriteria = AVDisplayCriteria(
|
||||||
|
refreshRate: Float(mode.refreshHz), formatDescription: desc)
|
||||||
|
sessionDisplayManager = manager
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
/// The display scale to render the metal drawable at. `traitCollection.displayScale` is the
|
||||||
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
/// canonical render scale and is reliable once the controller is in the hierarchy;
|
||||||
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
/// `view.contentScaleFactor` can read 1.0 before the view attaches to a window/screen, which
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import CoreMedia
|
||||||
|
import CoreVideo
|
||||||
|
import VideoToolbox
|
||||||
|
import XCTest
|
||||||
|
import simd
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Golden end-to-end colour tests: decode the known-signaling bar fixtures through a real
|
||||||
|
/// `VTDecompressionSession`, read the buffer's propagated signaling via `CscRows.signal(of:)`,
|
||||||
|
/// convert sampled Y′CbCr through `CscRows.rows` — the exact math the Metal shaders run — and
|
||||||
|
/// require the ORIGINAL RGB bars back. This is the proof of the two assumptions the stage-2
|
||||||
|
/// colour fix rests on: (1) VideoToolbox propagates the bitstream's matrix onto the decoded
|
||||||
|
/// CVPixelBuffer's attachments, and (2) signal+rows renders it correctly for BT.601/709 ×
|
||||||
|
/// limited/full. A hardcoded-709 regression fails the 601 fixture by tens of code points.
|
||||||
|
final class ColorBarDecodeTests: XCTestCase {
|
||||||
|
private static let bars: [(r: Float, g: Float, b: Float)] = [
|
||||||
|
(255, 255, 255), (255, 255, 0), (0, 255, 255), (0, 255, 0),
|
||||||
|
(255, 0, 255), (255, 0, 0), (0, 0, 255), (0, 0, 0),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Decode one fixture AU to a biplanar 4:2:0 buffer of the given range sibling.
|
||||||
|
private func decode(_ au: [UInt8], pixelFormat: OSType) throws -> CVPixelBuffer {
|
||||||
|
let data = Data(au)
|
||||||
|
guard let format = AnnexB.formatDescription(fromIDR: data, codec: .hevc) else {
|
||||||
|
throw XCTSkip("could not build a format description from the fixture")
|
||||||
|
}
|
||||||
|
let attrs: [CFString: Any] = [kCVPixelBufferPixelFormatTypeKey: pixelFormat]
|
||||||
|
var session: VTDecompressionSession?
|
||||||
|
let created = VTDecompressionSessionCreate(
|
||||||
|
allocator: kCFAllocatorDefault, formatDescription: format,
|
||||||
|
decoderSpecification: nil, imageBufferAttributes: attrs as CFDictionary,
|
||||||
|
outputCallback: nil, decompressionSessionOut: &session)
|
||||||
|
guard created == noErr, let session else {
|
||||||
|
throw XCTSkip("VTDecompressionSessionCreate failed (\(created))")
|
||||||
|
}
|
||||||
|
defer { VTDecompressionSessionInvalidate(session) }
|
||||||
|
let unit = AccessUnit(data: data, ptsNs: 0, frameIndex: 0, flags: 0, receivedNs: 0)
|
||||||
|
guard let sample = AnnexB.sampleBuffer(au: unit, format: format, codec: .hevc) else {
|
||||||
|
throw XCTSkip("could not build a sample buffer")
|
||||||
|
}
|
||||||
|
var produced: CVPixelBuffer?
|
||||||
|
let status = VTDecompressionSessionDecodeFrame(
|
||||||
|
session, sampleBuffer: sample, flags: [], infoFlagsOut: nil
|
||||||
|
) { status, _, imageBuffer, _, _ in
|
||||||
|
if status == noErr { produced = imageBuffer }
|
||||||
|
}
|
||||||
|
XCTAssertEqual(status, noErr, "decode submit")
|
||||||
|
VTDecompressionSessionWaitForAsynchronousFrames(session)
|
||||||
|
return try XCTUnwrap(produced, "no decoded frame")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertBars(
|
||||||
|
_ name: String, au: [UInt8], pixelFormat: OSType,
|
||||||
|
expected: CscRows.Signal
|
||||||
|
) throws {
|
||||||
|
let buffer = try decode(au, pixelFormat: pixelFormat)
|
||||||
|
let signal = CscRows.signal(of: buffer)
|
||||||
|
XCTAssertEqual(signal, expected, "\(name): VT must propagate the bitstream signaling")
|
||||||
|
|
||||||
|
let rows = CscRows.rows(signal, depth: 8, msbPacked: false)
|
||||||
|
CVPixelBufferLockBaseAddress(buffer, .readOnly)
|
||||||
|
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
|
||||||
|
let yBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 0))
|
||||||
|
.assumingMemoryBound(to: UInt8.self)
|
||||||
|
let yStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 0)
|
||||||
|
let cBase = try XCTUnwrap(CVPixelBufferGetBaseAddressOfPlane(buffer, 1))
|
||||||
|
.assumingMemoryBound(to: UInt8.self)
|
||||||
|
let cStride = CVPixelBufferGetBytesPerRowOfPlane(buffer, 1)
|
||||||
|
|
||||||
|
for (i, bar) in Self.bars.enumerated() {
|
||||||
|
let (cx, cy) = (i * 32 + 16, 32)
|
||||||
|
let y = Float(yBase[cy * yStride + cx]) / 255.0
|
||||||
|
let cb = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2]) / 255.0
|
||||||
|
let cr = Float(cBase[(cy / 2) * cStride + (cx / 2) * 2 + 1]) / 255.0
|
||||||
|
let yuv = SIMD3<Float>(y, cb, cr)
|
||||||
|
let rgb = SIMD3<Float>(
|
||||||
|
simd_dot(SIMD3(rows.r0.x, rows.r0.y, rows.r0.z), yuv) + rows.r0.w,
|
||||||
|
simd_dot(SIMD3(rows.r1.x, rows.r1.y, rows.r1.z), yuv) + rows.r1.w,
|
||||||
|
simd_dot(SIMD3(rows.r2.x, rows.r2.y, rows.r2.z), yuv) + rows.r2.w)
|
||||||
|
XCTAssertEqual(rgb.x * 255, bar.r, accuracy: 3, "\(name) bar \(i) R")
|
||||||
|
XCTAssertEqual(rgb.y * 255, bar.g, accuracy: 3, "\(name) bar \(i) G")
|
||||||
|
XCTAssertEqual(rgb.z * 255, bar.b, accuracy: 3, "\(name) bar \(i) B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.601 (BT.470BG) limited — what a Linux host's RGB-input NVENC signals. The fixture that
|
||||||
|
/// catches a hardcoded-BT.709 shader.
|
||||||
|
func testGolden601LimitedBars() throws {
|
||||||
|
try assertBars(
|
||||||
|
"601-limited", au: ColorBarFixtures.bars601Limited,
|
||||||
|
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||||
|
expected: .init(matrix: 5, fullRange: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.709 limited — the hosts' explicit SDR signaling.
|
||||||
|
func testGolden709LimitedBars() throws {
|
||||||
|
try assertBars(
|
||||||
|
"709-limited", au: ColorBarFixtures.bars709Limited,
|
||||||
|
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,
|
||||||
|
expected: .init(matrix: 1, fullRange: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.709 full range — the PUNKTFUNK_444_FULLRANGE experiment's signaling (requesting the
|
||||||
|
/// full-range sibling keeps VT from range-converting, so the full-range rows are exercised).
|
||||||
|
func testGolden709FullBars() throws {
|
||||||
|
try assertBars(
|
||||||
|
"709-full", au: ColorBarFixtures.bars709Full,
|
||||||
|
pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,
|
||||||
|
expected: .init(matrix: 1, fullRange: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,864 @@
|
|||||||
|
// Golden colour-bar fixtures — the SAME bytes as crates/pf-client-core/tests/bars-*.h265
|
||||||
|
// (one 256×64 LOSSLESS x265 IDR of 8 saturated bars per signaling variant; generated
|
||||||
|
// offline with ffmpeg/libx265, RGB→YUV matched to the declared VUI so the original RGB
|
||||||
|
// is recoverable ±1 code). Regenerate both together — the Rust and Swift golden tests
|
||||||
|
// must chew identical streams. Test-target only; nothing here ships.
|
||||||
|
|
||||||
|
enum ColorBarFixtures {
|
||||||
|
static let bars601Limited: [UInt8] = [
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||||
|
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||||
|
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x0a,
|
||||||
|
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||||
|
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||||
|
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||||
|
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||||
|
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||||
|
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||||
|
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||||
|
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||||
|
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||||
|
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||||
|
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||||
|
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||||
|
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||||
|
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||||
|
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||||
|
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||||
|
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||||
|
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||||
|
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||||
|
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||||
|
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||||
|
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||||
|
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||||
|
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||||
|
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||||
|
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||||
|
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||||
|
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||||
|
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||||
|
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||||
|
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||||
|
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||||
|
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||||
|
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||||
|
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||||
|
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||||
|
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||||
|
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||||
|
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||||
|
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||||
|
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||||
|
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||||
|
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||||
|
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||||
|
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||||
|
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||||
|
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||||
|
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||||
|
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||||
|
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||||
|
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||||
|
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||||
|
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||||
|
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||||
|
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||||
|
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||||
|
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||||
|
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||||
|
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||||
|
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||||
|
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||||
|
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||||
|
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||||
|
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||||
|
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||||
|
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||||
|
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||||
|
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||||
|
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||||
|
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||||
|
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
|
||||||
|
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||||
|
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||||
|
0x72, 0x69, 0x78, 0x3d, 0x35, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||||
|
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||||
|
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||||
|
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||||
|
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||||
|
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||||
|
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||||
|
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||||
|
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||||
|
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||||
|
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||||
|
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||||
|
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||||
|
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||||
|
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||||
|
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||||
|
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||||
|
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||||
|
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||||
|
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||||
|
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||||
|
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||||
|
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||||
|
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||||
|
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||||
|
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||||
|
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||||
|
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||||
|
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||||
|
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||||
|
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||||
|
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||||
|
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||||
|
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||||
|
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||||
|
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||||
|
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||||
|
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||||
|
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||||
|
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||||
|
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
|
||||||
|
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
|
||||||
|
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||||
|
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0xb5, 0xb6, 0x5d, 0x34, 0x79, 0xe7, 0x9e, 0x7f, 0x7d, 0xf7,
|
||||||
|
0xdf, 0x7d, 0xf8, 0x18, 0x53, 0xff, 0xfe, 0xc3, 0xa1, 0x01, 0xf1, 0xbc, 0xa9, 0x03, 0x52, 0x4f,
|
||||||
|
0xc2, 0x91, 0xef, 0xff, 0xf9, 0x13, 0xb5, 0xff, 0xff, 0xe0, 0xd3, 0xc0, 0xe8, 0xf7, 0xbe, 0xff,
|
||||||
|
0x98, 0x39, 0x83, 0x98, 0x39, 0x83, 0x99, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59, 0xb5, 0x9b, 0x59,
|
||||||
|
0xb5, 0x88, 0xe7, 0xff, 0xb1, 0xee, 0x80, 0x1f, 0xaf, 0xd3, 0xc3, 0x0c, 0x30, 0xc3, 0x0c, 0x71,
|
||||||
|
0xc7, 0x1c, 0x71, 0xc7, 0x16, 0x7a, 0x50, 0x01, 0xc9, 0xff, 0xf9, 0x43, 0x06, 0x41, 0x75, 0x47,
|
||||||
|
0xb1, 0xe7, 0x71, 0x8e, 0x4e, 0x9d, 0x1f, 0xff, 0xce, 0x91, 0x28, 0x4d, 0xc5, 0x7a, 0x68, 0x84,
|
||||||
|
0xdc, 0x76, 0x6c, 0xb4, 0xb3, 0x45, 0xc9, 0xef, 0xff, 0xcb, 0x9f, 0x2b, 0x6b, 0x50, 0x63, 0xb0,
|
||||||
|
0x1f, 0xe0, 0x49, 0xe5, 0x74, 0x7f, 0xff, 0x3a, 0x44, 0xa1, 0x37, 0x15, 0xe9, 0xa2, 0x13, 0x71,
|
||||||
|
0xd9, 0xaa, 0x79, 0xa3, 0xe3, 0xff, 0xf5, 0x23, 0x05, 0x46, 0x2f, 0x61, 0x97, 0x0a, 0x0e, 0x16,
|
||||||
|
0x36, 0x94, 0x4f, 0xff, 0xb3, 0x7f, 0x1f, 0xe6, 0x13, 0x25, 0x0f, 0x78, 0x49, 0x00, 0x63, 0xf9,
|
||||||
|
0x58, 0x64, 0x83, 0x85, 0x00, 0x07, 0x98, 0xce, 0xcb, 0x42, 0xb2, 0x35, 0x1d, 0xfa, 0xbf, 0xc6,
|
||||||
|
0x85, 0xe6, 0x00, 0x48, 0xff, 0xfe, 0xed, 0x0e, 0xf8, 0x8b, 0xfc, 0x55, 0xed, 0x8d, 0xa0, 0xbd,
|
||||||
|
0xf6, 0xdf, 0xff, 0xc7, 0xf9, 0xd8, 0x58, 0x24, 0x13, 0x43, 0x41, 0x3e, 0x1f, 0xc8, 0x40, 0x1c,
|
||||||
|
0x5f, 0xff, 0x95, 0x21, 0x6f, 0x83, 0x90, 0x1c, 0x23, 0xc9, 0xe6, 0x5e, 0xdb, 0xd7, 0xff, 0xde,
|
||||||
|
0xb1, 0x0b, 0xeb, 0x42, 0x9f, 0xe3, 0xae, 0xdb, 0x41, 0x26, 0x3b, 0x04, 0x56, 0x7f, 0xfe, 0xc9,
|
||||||
|
0xf5, 0x58, 0x41, 0xd2, 0xa9, 0xeb, 0x7f, 0xd7, 0x3b, 0xa2, 0xef, 0xff, 0xde, 0x56, 0xa7, 0xa2,
|
||||||
|
0xac, 0x43, 0x81, 0xaf, 0xce, 0x33, 0x5c, 0x41, 0xbf, 0x1f, 0xff, 0x5d, 0xee, 0xcb, 0x39, 0xae,
|
||||||
|
0x1e, 0xa3, 0xd4, 0x47, 0xc7, 0xe9, 0x47, 0xff, 0xeb, 0x2a, 0xe3, 0x45, 0x58, 0xb5, 0x6a, 0x37,
|
||||||
|
0xfd, 0xd2, 0xcf, 0xfa, 0x80, 0xa2, 0x8c, 0xff, 0xff, 0xf0, 0x0f, 0x7e, 0x56, 0xf1, 0x4e, 0x9d,
|
||||||
|
0x3a, 0x74, 0xf2, 0x64, 0xc9, 0x93, 0x26, 0x4c, 0x98, 0x43, 0xff, 0xff, 0x77, 0x88, 0xb7, 0xf6,
|
||||||
|
0x16, 0xba, 0x3e, 0xa1, 0xc4, 0xf8, 0x98, 0xc5, 0xc5, 0x67, 0xf6, 0xa6, 0x20, 0x0b, 0x3d, 0x62,
|
||||||
|
0x9e, 0xaf, 0xe6, 0xaa, 0x45, 0x3e, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xfd, 0x2f, 0xd2,
|
||||||
|
0xfd, 0x2f, 0xd2, 0xfd, 0x2f, 0xd2, 0xfd, 0x2d, 0x3d, 0xfe, 0x6b, 0xf7, 0xff, 0xff, 0x04, 0x5e,
|
||||||
|
0x01, 0x17, 0x8a, 0x34, 0xae, 0x56, 0xe5, 0x6e, 0x56, 0xe5, 0x6e, 0x66, 0x26, 0x62, 0x66, 0x26,
|
||||||
|
0x62, 0x66, 0x26, 0x62, 0x66, 0x25, 0xe8, 0x81, 0xb7, 0xff, 0xfc, 0x8c, 0xfc, 0x2d, 0xdd, 0xfc,
|
||||||
|
0x78, 0xe0, 0x8f, 0x36, 0xc1, 0x09, 0xb5, 0x10, 0xff, 0xff, 0x05, 0x24, 0xbb, 0x93, 0x6d, 0x8b,
|
||||||
|
0x11, 0x3f, 0xf5, 0x5f, 0x91, 0x4d, 0xe8, 0x34, 0x32, 0x13, 0xff, 0xf2, 0xd0, 0x8e, 0xa4, 0x10,
|
||||||
|
0x3b, 0x17, 0x3d, 0x0d, 0xf0, 0x64, 0x88, 0x64, 0xd4, 0xff, 0xfd, 0xe4, 0x28, 0x23, 0x05, 0xf8,
|
||||||
|
0x06, 0x68, 0xb4, 0xa5, 0x8c, 0x20, 0x1e, 0x54, 0x22, 0xf3, 0xff, 0xf6, 0x32, 0x77, 0x6d, 0x34,
|
||||||
|
0xce, 0xe5, 0x7e, 0xdf, 0xcc, 0x43, 0x72, 0x2c, 0xdf, 0x7f, 0xfe, 0x7f, 0xb0, 0x15, 0x20, 0x52,
|
||||||
|
0xd4, 0x06, 0xf8, 0x83, 0x2d, 0x08, 0x97, 0x2f, 0x08, 0x3d, 0x3d, 0x5f, 0xff, 0xe6, 0x0a, 0xa2,
|
||||||
|
0x05, 0xf9, 0x24, 0x92, 0x4b, 0x18, 0x61, 0x86, 0x18, 0x61, 0x88, 0x6f, 0xff, 0xd4, 0xec, 0x35,
|
||||||
|
0x68, 0x57, 0xf9, 0x4c, 0x73, 0xc7, 0x6e, 0x6d, 0x63, 0x89, 0x93, 0xff, 0xff, 0x6e, 0x4e, 0xd9,
|
||||||
|
0x23, 0x2c, 0xf5, 0xed, 0xef, 0xde, 0xfd, 0xef, 0xde, 0xfd, 0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x6c,
|
||||||
|
0xf6, 0xcf, 0x6c, 0xf6, 0xcf, 0x01, 0x1b, 0x12, 0xc0, 0x18, 0x22, 0xee, 0x4d, 0x92, 0x49, 0x24,
|
||||||
|
0x92, 0x7a, 0xaa, 0xaa, 0xaa, 0xaa, 0xa7, 0xd0, 0x1a, 0x5f, 0xff, 0x9c, 0xa9, 0xca, 0xe5, 0x6d,
|
||||||
|
0x24, 0x4e, 0x60, 0x52, 0x2f, 0xa6, 0x7f, 0xff, 0xb0, 0xe8, 0x40, 0x7c, 0x6f, 0x2a, 0x40, 0xd4,
|
||||||
|
0x93, 0xf0, 0xa6, 0xa6, 0x14, 0x55, 0xff, 0xf3, 0x76, 0x99, 0x75, 0xaa, 0xeb, 0x16, 0xc8, 0x7d,
|
||||||
|
0x93, 0x68, 0x60, 0xff, 0xfd, 0x65, 0x5c, 0x68, 0xab, 0x16, 0xad, 0x46, 0xff, 0xba, 0x5a, 0x12,
|
||||||
|
0x2c, 0x9f, 0xff, 0xe5, 0x39, 0x42, 0xf7, 0xb8, 0x97, 0xfb, 0xe6, 0xf7, 0xd3, 0x1e, 0x27, 0xff,
|
||||||
|
0xf9, 0xc3, 0x14, 0x52, 0xf4, 0xf5, 0x3c, 0xbb, 0x6e, 0xb7, 0x2b, 0xbb, 0x80, 0x5e, 0xbf, 0xff,
|
||||||
|
0x41, 0xd9, 0xcb, 0x9f, 0x5d, 0xc9, 0x28, 0x36, 0x1c, 0xcf, 0x18, 0xa2, 0xd9, 0xdf, 0xff, 0xa7,
|
||||||
|
0xf3, 0x09, 0x84, 0xe6, 0x31, 0x94, 0xa5, 0x8d, 0xad, 0x93, 0x83, 0x8c, 0x01, 0x58, 0xff, 0xfd,
|
||||||
|
0x4b, 0xa2, 0xd2, 0x82, 0x6b, 0xc7, 0x81, 0x87, 0x90, 0xc0, 0x16, 0x28, 0x4c, 0x7f, 0xfe, 0x95,
|
||||||
|
0xc3, 0x60, 0xc7, 0x92, 0xea, 0x9a, 0xf4, 0xee, 0x9b, 0x68, 0x59, 0x04, 0x74, 0xec, 0x80, 0x0f,
|
||||||
|
0x41, 0x5c, 0xee, 0x30, 0xf1, 0xc6, 0xe8, 0x40, 0x12, 0xed, 0xb5, 0x55, 0xff, 0xfc, 0x11, 0xc8,
|
||||||
|
0x03, 0x4d, 0x33, 0x0f, 0x22, 0xec, 0x22, 0xdf, 0x56, 0x44, 0xb6, 0x7f, 0xff, 0x12, 0x30, 0x22,
|
||||||
|
0x76, 0x41, 0xf5, 0x7f, 0xf8, 0x06, 0x4f, 0x55, 0x86, 0x31, 0x58, 0xa3, 0xff, 0xf4, 0xf1, 0xc2,
|
||||||
|
0x65, 0x14, 0x4c, 0x4d, 0x40, 0x92, 0x12, 0x88, 0x96, 0xdb, 0xc4, 0xff, 0xfd, 0x33, 0x8d, 0xc5,
|
||||||
|
0xcb, 0xf7, 0x51, 0xc8, 0xd1, 0x4a, 0x19, 0x22, 0x0f, 0x81, 0xee, 0x95, 0xff, 0xff, 0x9c, 0x31,
|
||||||
|
0x45, 0x2f, 0x4f, 0x53, 0xcb, 0xb6, 0xeb, 0x72, 0xca, 0xaf, 0xff, 0x9b, 0x7c, 0x7c, 0x9e, 0x22,
|
||||||
|
0x9b, 0x07, 0xb8, 0xd1, 0x57, 0x20, 0x17, 0x9d, 0xff, 0xf8, 0x24, 0xc8, 0xb0, 0xee, 0x78, 0x7b,
|
||||||
|
0x95, 0x1d, 0x8d, 0x56, 0x89, 0xff, 0xf8, 0x15, 0xb7, 0xfa, 0x2a, 0xbe, 0x6b, 0x3f, 0xf0, 0xb5,
|
||||||
|
0xb3, 0x63, 0x74, 0x55, 0xff, 0xfc, 0xcc, 0xbd, 0x0e, 0x72, 0xf9, 0x76, 0x87, 0x1d, 0x0e, 0xd9,
|
||||||
|
0x12, 0x9f, 0xff, 0x32, 0x43, 0xc0, 0x1f, 0x1b, 0x10, 0xf4, 0xd3, 0x9f, 0x4b, 0xd2, 0x6b, 0x2f,
|
||||||
|
0xff, 0xef, 0xcb, 0x14, 0x52, 0x91, 0x69, 0x76, 0xc8, 0x6d, 0x94, 0x00, 0x34, 0xff, 0xfb, 0xec,
|
||||||
|
0xba, 0x8f, 0x8d, 0xeb, 0x14, 0xf5, 0x9e, 0xab, 0x96, 0xf0, 0xd9, 0x4f, 0x39, 0x0f, 0xff, 0xf8,
|
||||||
|
0x50, 0x81, 0x7f, 0x73, 0xb2, 0xe5, 0xcb, 0x97, 0x32, 0x6c, 0xd9, 0xb3, 0x66, 0xcd, 0x9b, 0x61,
|
||||||
|
0x7f, 0xff, 0x74, 0x86, 0x0d, 0xaf, 0x45, 0xff, 0x85, 0x5a, 0xa4, 0x21, 0x8d, 0xfd, 0x18, 0x3a,
|
||||||
|
0x85, 0x00, 0x5d, 0x6a, 0x15, 0xf4, 0xff, 0x44, 0xd0, 0x78, 0xe5, 0xaf, 0x7a, 0xf7, 0xaf, 0x7a,
|
||||||
|
0xf7, 0xb0, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf1, 0x0f, 0x10, 0xf0, 0xf8, 0x37, 0xae, 0xc0,
|
||||||
|
0x38, 0xb4, 0xea, 0x7a, 0x6e, 0x22, 0x51, 0xbc, 0x31, 0xdd, 0x3b, 0x74, 0xed, 0xd3, 0xb7, 0x4e,
|
||||||
|
0xdd, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x5d, 0x0d, 0x74, 0x35, 0xd0, 0xd7, 0x43, 0x56, 0xc8, 0x91,
|
||||||
|
0x7f, 0xff, 0xa1, 0xad, 0x88, 0x55, 0x61, 0x10, 0x40, 0x65, 0xd0, 0x9b, 0x4b, 0xc3, 0x5b, 0x8f,
|
||||||
|
0xff, 0xd0, 0xb6, 0xa8, 0x17, 0xbd, 0x41, 0xad, 0xe6, 0xc9, 0x9a, 0x1c, 0x1c, 0x1b, 0x5d, 0xa6,
|
||||||
|
0x97, 0xff, 0xf4, 0x29, 0x51, 0x3f, 0xff, 0xfa, 0xae, 0x19, 0xa1, 0x7e, 0x73, 0xcd, 0xc7, 0xfa,
|
||||||
|
0xff, 0xfd, 0x07, 0x66, 0xff, 0x1d, 0x6b, 0x4c, 0x94, 0xe8, 0xc3, 0x30, 0x89, 0x10, 0xd4, 0x81,
|
||||||
|
0x17, 0xff, 0xe4, 0x00, 0x31, 0xf0, 0xc2, 0xff, 0x9b, 0xc2, 0xb6, 0xd5, 0xbc, 0x8d, 0x4a, 0xbf,
|
||||||
|
0xff, 0xf8, 0xf9, 0xdb, 0xda, 0xc8, 0x96, 0xff, 0x59, 0x48, 0xb9, 0xdc, 0xca, 0x74, 0x56, 0x89,
|
||||||
|
0x79, 0x8c, 0xff, 0xff, 0x11, 0xf1, 0x1f, 0x0b, 0x12, 0x49, 0x24, 0xa4, 0x30, 0xc3, 0x0c, 0x30,
|
||||||
|
0xc4, 0x34, 0xff, 0xfd, 0xec, 0xf2, 0xd5, 0x8c, 0xa1, 0x40, 0x45, 0x93, 0x4f, 0x05, 0x8a, 0xdb,
|
||||||
|
0xbf, 0xff, 0xfc, 0x63, 0xf8, 0xb1, 0xe1, 0x66, 0xf5, 0x2c, 0x92, 0xc9, 0x2c, 0x92, 0xc9, 0x2c,
|
||||||
|
0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xcb, 0xec, 0xbe, 0xc9, 0x46, 0xf9, 0x00, 0x24, 0xe2,
|
||||||
|
0xac, 0x81, 0x0c, 0x30, 0xc3, 0x0c, 0x7c, 0x71, 0xc7, 0x1c, 0x71, 0xc3, 0x33, 0xbe, 0x7f, 0xff,
|
||||||
|
0x09, 0x4e, 0x4f, 0x3a, 0xf9, 0x34, 0x8d, 0x41, 0xb5, 0x0d, 0x42, 0xbf, 0xfe, 0xf5, 0x88, 0x5f,
|
||||||
|
0x5a, 0x14, 0xff, 0x1d, 0x76, 0xda, 0x09, 0x36, 0xa4, 0x5d, 0xaf, 0xff, 0xbd, 0x87, 0x69, 0x9c,
|
||||||
|
0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e, 0x17, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1,
|
||||||
|
0xd7, 0x6d, 0xa0, 0x93, 0x60, 0x15, 0xbf, 0xfe, 0xf6, 0x1d, 0xa6, 0x73, 0x67, 0xdc, 0x31, 0x22,
|
||||||
|
0x62, 0x78, 0x5d, 0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x82, 0x4d,
|
||||||
|
0xcc, 0x83, 0xff, 0xff, 0xf1, 0xd3, 0x9b, 0xa2, 0x9d, 0xe7, 0x8c, 0x66, 0x72, 0xc0, 0x2f, 0xcf,
|
||||||
|
0x65, 0x9b, 0xff, 0xf7, 0xc0, 0xca, 0x22, 0x04, 0xa8, 0x24, 0xf3, 0x1d, 0x63, 0x5d, 0x84, 0x8f,
|
||||||
|
0xff, 0x44, 0x5f, 0xff, 0x9f, 0xeb, 0xff, 0x4c, 0x13, 0x36, 0x00, 0x3c, 0x22, 0xc9, 0xc2, 0xf5,
|
||||||
|
0xf9, 0xef, 0xff, 0xcf, 0xf5, 0xff, 0xa6, 0x09, 0x9b, 0x00, 0x1e, 0x11, 0x64, 0xe1, 0x7a, 0xf0,
|
||||||
|
0x2a, 0x80, 0xe0, 0x04, 0x5a, 0x21, 0x7a, 0xa9, 0x51, 0x5c, 0x9d, 0x9c, 0x8a, 0x61, 0xc3, 0xd2,
|
||||||
|
0xff, 0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf,
|
||||||
|
0xff, 0x72, 0x03, 0xdc, 0x34, 0x44, 0x7e, 0x97, 0x68, 0x3e, 0x1a, 0xca, 0x8d, 0xf6, 0xb9, 0xc9,
|
||||||
|
0xff, 0xf9, 0x98, 0x62, 0x79, 0xa6, 0x97, 0x88, 0xdb, 0x12, 0xa0, 0xdb, 0x18, 0x4a, 0x52, 0x7f,
|
||||||
|
0xfe, 0x65, 0x99, 0x2d, 0xca, 0xa3, 0x9b, 0x23, 0x17, 0x99, 0x47, 0x1b, 0x57, 0x6b, 0x24, 0xda,
|
||||||
|
0x4f, 0xff, 0x9f, 0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfc, 0x3e, 0xcf, 0xff, 0x9f,
|
||||||
|
0xb9, 0x30, 0x05, 0x2e, 0xef, 0x9f, 0xad, 0x6d, 0xfb, 0xed, 0x2b, 0xed, 0xff, 0xf5, 0xca, 0x2c,
|
||||||
|
0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x14, 0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65,
|
||||||
|
0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x2d, 0x3c, 0xe4, 0xff, 0xf9, 0xfe, 0xf1, 0xf6, 0xb4, 0x7a, 0xc8,
|
||||||
|
0x10, 0x48, 0x21, 0xb8, 0xfb, 0x3f, 0xfe, 0x7e, 0xe4, 0xc0, 0x14, 0xbb, 0xbe, 0x7e, 0xb5, 0xb7,
|
||||||
|
0xef, 0xb4, 0xfa, 0x17, 0xff, 0xd7, 0x3f, 0x91, 0xb5, 0xa5, 0x4a, 0x1d, 0xb1, 0x7b, 0x6a, 0xd4,
|
||||||
|
0xc5, 0xff, 0xf5, 0xca, 0x2c, 0x22, 0x59, 0x65, 0xee, 0x46, 0x1b, 0xdc, 0x0c, 0x28, 0xe4, 0xd2,
|
||||||
|
0xe3, 0x5f, 0xff, 0xde, 0xd4, 0xf2, 0x31, 0x27, 0x12, 0xa5, 0x4a, 0x95, 0x3c, 0x28, 0x50, 0xa1,
|
||||||
|
0x42, 0x85, 0x06, 0xda, 0xff, 0xfd, 0xc8, 0x0f, 0x94, 0x31, 0x1a, 0xd2, 0x66, 0x8a, 0xa8, 0x74,
|
||||||
|
0x03, 0x52, 0xcf, 0x12, 0x00, 0x52, 0x6c, 0xd3, 0x36, 0x5f, 0x46, 0x35, 0xfb, 0xc6, 0xbf, 0x4b,
|
||||||
|
0xf4, 0xbf, 0x4b, 0xf4, 0xc0, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x96, 0x09, 0x60, 0x95, 0xf5,
|
||||||
|
0xab, 0x2f, 0xff, 0xfe, 0xd2, 0xdd, 0x9c, 0x95, 0xe0, 0x25, 0xdd, 0x39, 0xd3, 0x9d, 0x39, 0xd3,
|
||||||
|
0x9d, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x14, 0xc1, 0x4c, 0x13, 0xab, 0xb0, 0x0b, 0xff,
|
||||||
|
0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x52, 0xf5, 0xff, 0xfb,
|
||||||
|
0x90, 0x1f, 0x28, 0x62, 0x35, 0xa4, 0xcd, 0x15, 0x50, 0xe8, 0x06, 0xa5, 0xb2, 0xce, 0xbf, 0x7f,
|
||||||
|
0xff, 0x14, 0x7b, 0x0e, 0x08, 0x20, 0x18, 0x32, 0x3e, 0xeb, 0x9a, 0xc6, 0xee, 0x81, 0xdf, 0xff,
|
||||||
|
0xc5, 0x0c, 0x78, 0xe1, 0x5d, 0x95, 0x29, 0x2c, 0x78, 0x61, 0xfa, 0x77, 0x12, 0x37, 0x16, 0xff,
|
||||||
|
0xfd, 0xc8, 0xbf, 0xcc, 0xd3, 0x4c, 0x35, 0xeb, 0x66, 0x85, 0xe3, 0xe5, 0xaa, 0x5e, 0xbf, 0xff,
|
||||||
|
0x72, 0x03, 0xe5, 0x0c, 0x46, 0xb4, 0x99, 0xa2, 0xaa, 0x1d, 0x00, 0xd4, 0xb6, 0xe7, 0x39, 0xf1,
|
||||||
|
0xaf, 0xff, 0xe2, 0x23, 0x20, 0x60, 0x5e, 0x18, 0x61, 0x88, 0x57, 0x5d, 0x75, 0xd7, 0x5d, 0x5b,
|
||||||
|
0x2f, 0xff, 0xbd, 0x62, 0x17, 0xd6, 0x85, 0x3f, 0xc7, 0x5d, 0xb6, 0x81, 0xde, 0x79, 0xff, 0xff,
|
||||||
|
0xf5, 0x01, 0xe9, 0xc0, 0xa2, 0xd0, 0xd3, 0x64, 0xd6, 0x4d, 0x64, 0xd6, 0x4d, 0x65, 0x5c, 0x55,
|
||||||
|
0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x55, 0xc5, 0x5c, 0x4d, 0xaa, 0x10, 0x05, 0xd9, 0x33, 0xd5, 0xc0,
|
||||||
|
0x00, 0x00, 0x03, 0x00, 0x0b, 0xff, 0xff, 0xff, 0xff, 0xfd, 0xc6, 0x42, 0xf7, 0xff, 0xd7, 0x28,
|
||||||
|
0xb0, 0x89, 0x65, 0x97, 0xb9, 0x18, 0x6f, 0x70, 0x53, 0x17, 0xff, 0xd7, 0x28, 0xb0, 0x89, 0x65,
|
||||||
|
0x97, 0xb9, 0x18, 0x6f, 0x70, 0x30, 0x56, 0xfd, 0x5b, 0xff, 0xf2, 0x33, 0x67, 0x90, 0x81, 0x60,
|
||||||
|
0x4a, 0x3d, 0x34, 0x7d, 0x12, 0xe6, 0xff, 0xfc, 0x8a, 0xea, 0x0e, 0x95, 0x0c, 0xc0, 0x73, 0xf5,
|
||||||
|
0x83, 0xaf, 0xfa, 0x2f, 0x2f, 0xff, 0xbd, 0x87, 0x69, 0x9c, 0xd9, 0xf7, 0x0c, 0x48, 0x98, 0x9e,
|
||||||
|
0x07, 0x4b, 0xff, 0xef, 0x58, 0x85, 0xf5, 0xa1, 0x4f, 0xf1, 0xd7, 0x6d, 0xa0, 0x78, 0x35, 0x4a,
|
||||||
|
0x6d, 0xff, 0xfc, 0x50, 0xc7, 0x8e, 0x15, 0xd9, 0x52, 0x92, 0xc7, 0x86, 0x1f, 0xa7, 0x74, 0x0e,
|
||||||
|
0xff, 0xfe, 0x28, 0x63, 0xc7, 0x0a, 0xec, 0xa9, 0x49, 0x63, 0xc3, 0x0f, 0xd3, 0xb8, 0x92, 0x9f,
|
||||||
|
0x57, 0xff, 0xee, 0x40, 0x7c, 0xa1, 0x88, 0xd6, 0x93, 0x34, 0x55, 0x43, 0xa0, 0x1a, 0xa5, 0xeb,
|
||||||
|
0xff, 0xf7, 0x20, 0x3e, 0x50, 0xc4, 0x6b, 0x49, 0x9a, 0x2a, 0xa1, 0xd0, 0x0d, 0x4b, 0x13, 0x9a,
|
||||||
|
0xfc, 0x01, 0x0a, 0xb8, 0xdc, 0xe9, 0xaa, 0x51, 0xa6, 0x2f, 0x33, 0x38, 0x70, 0x9f, 0x3f, 0xff,
|
||||||
|
0x81, 0x38, 0x6f, 0x96, 0x59, 0x2c, 0x9d, 0xc5, 0x46, 0x2d, 0xbb, 0xb2, 0x86, 0x2f, 0xff, 0xde,
|
||||||
|
0x02, 0x4a, 0x0e, 0x78, 0xf4, 0x81, 0xf4, 0x0e, 0xf1, 0xaf, 0x76, 0xc4, 0x68, 0x3a, 0x7f, 0xfe,
|
||||||
|
0x7a, 0x23, 0xee, 0xeb, 0xae, 0x0b, 0xba, 0xa9, 0x83, 0xd2, 0x73, 0xc7, 0xd0, 0x9f, 0xff, 0x9e,
|
||||||
|
0x6a, 0xb4, 0x7c, 0xad, 0x6d, 0xa6, 0x32, 0xbc, 0x60, 0xd2, 0xe3, 0x9c, 0x90, 0x95, 0xe3, 0xff,
|
||||||
|
0xe9, 0x1b, 0x9a, 0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0xac, 0xb3, 0xff, 0xe9, 0x1b, 0x9a,
|
||||||
|
0x48, 0x96, 0x45, 0x2e, 0x92, 0xdc, 0x57, 0x99, 0x5e, 0x95, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9,
|
||||||
|
0xc1, 0x16, 0xba, 0xb0, 0xf7, 0x82, 0xfd, 0x7f, 0xfe, 0x01, 0xa9, 0xd0, 0xd9, 0xc1, 0x16, 0xba,
|
||||||
|
0xb0, 0xf7, 0x81, 0x46, 0x41, 0x3d, 0xbf, 0xff, 0x01, 0x63, 0xdb, 0x18, 0x93, 0x66, 0x4d, 0xce,
|
||||||
|
0x9b, 0xce, 0x5f, 0xaf, 0xff, 0xc0, 0x35, 0x3a, 0x1b, 0x38, 0x22, 0xd7, 0x56, 0x1e, 0xf0, 0x28,
|
||||||
|
0x50, 0x74, 0xff, 0xfa, 0x4a, 0x1b, 0x55, 0xac, 0x47, 0xb6, 0x24, 0xc4, 0x4a, 0xa2, 0xcb, 0x3f,
|
||||||
|
0xfe, 0x91, 0xb9, 0xa4, 0x89, 0x64, 0x52, 0xe9, 0x2d, 0xc5, 0x79, 0x98, 0x82, 0x80,
|
||||||
|
]
|
||||||
|
|
||||||
|
static let bars709Limited: [UInt8] = [
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||||
|
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||||
|
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6a, 0x02, 0x02, 0x02,
|
||||||
|
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||||
|
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||||
|
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||||
|
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||||
|
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||||
|
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||||
|
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||||
|
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||||
|
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||||
|
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||||
|
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||||
|
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||||
|
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||||
|
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||||
|
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||||
|
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||||
|
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||||
|
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||||
|
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||||
|
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||||
|
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||||
|
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||||
|
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||||
|
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||||
|
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||||
|
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||||
|
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||||
|
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||||
|
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||||
|
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||||
|
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||||
|
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||||
|
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||||
|
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||||
|
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||||
|
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||||
|
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||||
|
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||||
|
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||||
|
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||||
|
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||||
|
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||||
|
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||||
|
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||||
|
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||||
|
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||||
|
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||||
|
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||||
|
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||||
|
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||||
|
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||||
|
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||||
|
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||||
|
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||||
|
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||||
|
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||||
|
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||||
|
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||||
|
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||||
|
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||||
|
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||||
|
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||||
|
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||||
|
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||||
|
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||||
|
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||||
|
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||||
|
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||||
|
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||||
|
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||||
|
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x30,
|
||||||
|
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||||
|
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||||
|
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||||
|
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||||
|
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||||
|
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||||
|
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||||
|
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||||
|
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||||
|
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||||
|
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||||
|
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||||
|
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||||
|
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||||
|
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||||
|
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||||
|
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||||
|
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||||
|
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||||
|
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||||
|
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||||
|
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||||
|
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||||
|
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||||
|
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||||
|
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||||
|
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||||
|
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||||
|
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||||
|
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||||
|
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||||
|
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||||
|
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||||
|
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||||
|
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||||
|
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||||
|
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||||
|
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||||
|
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||||
|
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||||
|
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||||
|
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||||
|
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xbb, 0x82,
|
||||||
|
0x75, 0x79, 0xbd, 0x3c, 0x5f, 0x14, 0xb1, 0x4b, 0x14, 0xb1, 0x4b, 0x17, 0xc5, 0x7c, 0x57, 0xc5,
|
||||||
|
0x7c, 0x57, 0xc5, 0x7c, 0x57, 0xc5, 0x7d, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||||
|
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x7b, 0xb8, 0x0b, 0x9c, 0xe7, 0x3a, 0x29, 0x4a, 0x52, 0x94,
|
||||||
|
0xa5, 0xc2, 0x9f, 0xff, 0xe8, 0xfd, 0xf0, 0x63, 0x71, 0xfc, 0x63, 0xec, 0x8f, 0x38, 0xf7, 0xff,
|
||||||
|
0xfc, 0x89, 0xda, 0xff, 0xff, 0xf0, 0x69, 0xe0, 0x74, 0x7b, 0xdf, 0x7f, 0xcc, 0x1c, 0xc1, 0xcc,
|
||||||
|
0x1c, 0xc1, 0xcc, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xcd, 0xac, 0xda, 0xc4, 0x73, 0xff,
|
||||||
|
0xd8, 0xf7, 0x40, 0x0f, 0x9f, 0x18, 0xc6, 0x31, 0x8c, 0x67, 0x39, 0xce, 0x73, 0x9c, 0xb3, 0xd2,
|
||||||
|
0x80, 0x0e, 0x4f, 0xff, 0x82, 0x8d, 0x24, 0xc8, 0xe1, 0x4d, 0xd7, 0xae, 0x61, 0xc6, 0x8f, 0xff,
|
||||||
|
0xc6, 0x7a, 0xd8, 0xf8, 0x9c, 0xf6, 0xf9, 0xcf, 0x66, 0x3d, 0xa5, 0x9a, 0x2e, 0x4f, 0x7f, 0xfc,
|
||||||
|
0x55, 0x8a, 0xa8, 0xed, 0xa7, 0x75, 0xbb, 0xe9, 0x49, 0x47, 0xff, 0xe3, 0x3d, 0x6c, 0x7c, 0x4e,
|
||||||
|
0x7b, 0x7c, 0xe7, 0xb3, 0x16, 0x79, 0xa3, 0xe3, 0xff, 0xe7, 0xab, 0xaa, 0x9f, 0x21, 0x38, 0x65,
|
||||||
|
0xc4, 0xb8, 0x18, 0x9f, 0xfe, 0x9b, 0xec, 0x4b, 0x58, 0x5d, 0xf8, 0xe5, 0xdf, 0x53, 0x6f, 0x2b,
|
||||||
|
0x0c, 0x90, 0x70, 0xa0, 0x00, 0xee, 0xdd, 0x34, 0xd4, 0x1e, 0xb0, 0x10, 0x19, 0xe6, 0x85, 0xe6,
|
||||||
|
0x00, 0x48, 0xff, 0xfd, 0x80, 0x28, 0x35, 0x78, 0x31, 0x21, 0xb6, 0x44, 0xdc, 0xed, 0xff, 0xf7,
|
||||||
|
0xd9, 0xef, 0xcd, 0xe3, 0x09, 0x63, 0x63, 0xc8, 0x0d, 0x40, 0x1c, 0x5f, 0xff, 0x07, 0x4b, 0x3e,
|
||||||
|
0xdd, 0x9f, 0x5f, 0x13, 0x8f, 0x5e, 0x4d, 0x7f, 0xfb, 0x27, 0xc1, 0xaa, 0x45, 0xe8, 0x00, 0x78,
|
||||||
|
0x18, 0xd2, 0x7b, 0x04, 0x56, 0x7f, 0xfd, 0x49, 0x9f, 0xfe, 0xb6, 0xbb, 0x0b, 0x58, 0x86, 0x8b,
|
||||||
|
0xdf, 0xff, 0x63, 0x53, 0x7d, 0x4f, 0xd7, 0x6d, 0x83, 0xdd, 0x24, 0x58, 0x37, 0xe3, 0xff, 0xd2,
|
||||||
|
0xa0, 0xff, 0xea, 0x7c, 0x38, 0xbd, 0xc8, 0xcb, 0x41, 0xff, 0xf3, 0xe1, 0x33, 0x34, 0x62, 0xe7,
|
||||||
|
0x3d, 0xc8, 0x0b, 0x72, 0xea, 0x02, 0x8a, 0x33, 0xff, 0xff, 0xa6, 0xb4, 0x4c, 0x89, 0x44, 0x92,
|
||||||
|
0x49, 0x25, 0xa6, 0x9a, 0x69, 0xa6, 0x9a, 0x60, 0x3f, 0xff, 0xf3, 0x35, 0x41, 0x04, 0x41, 0xef,
|
||||||
|
0xf0, 0x95, 0xfa, 0x4c, 0x21, 0x48, 0x56, 0x7f, 0x6a, 0x62, 0x00, 0xb3, 0xd4, 0x0d, 0xe9, 0xf0,
|
||||||
|
0x69, 0x96, 0x4e, 0x10, 0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0x38, 0x43, 0x84, 0x38, 0x43, 0x84,
|
||||||
|
0x38, 0x43, 0x84, 0x38, 0x42, 0xdf, 0x7f, 0x9a, 0xfd, 0xff, 0xff, 0xb9, 0x77, 0x71, 0x05, 0xa5,
|
||||||
|
0x8b, 0x43, 0x77, 0x37, 0x73, 0x77, 0x37, 0x73, 0x7b, 0x07, 0xb0, 0x7b, 0x07, 0xb0, 0x7b, 0x07,
|
||||||
|
0xb0, 0x7b, 0x07, 0x92, 0x20, 0x6d, 0xff, 0xfe, 0xb4, 0xe9, 0xde, 0x13, 0xd7, 0xc9, 0xbc, 0x56,
|
||||||
|
0xf3, 0xee, 0xa8, 0xe4, 0x3f, 0xff, 0xa1, 0xf0, 0xfc, 0x2e, 0x5d, 0x4d, 0xac, 0xc7, 0x3f, 0x7b,
|
||||||
|
0x12, 0xf6, 0x0d, 0x0c, 0x84, 0xff, 0xfb, 0x0e, 0x4f, 0x86, 0x62, 0xe4, 0x29, 0xcd, 0x16, 0x0d,
|
||||||
|
0x1c, 0xd5, 0xf4, 0xff, 0xfc, 0xd6, 0x57, 0xd6, 0xef, 0x5c, 0x7f, 0xf4, 0x23, 0xc4, 0x3b, 0xff,
|
||||||
|
0x44, 0x22, 0xf3, 0xff, 0xf1, 0x4c, 0x00, 0x90, 0x41, 0x74, 0x0e, 0xdb, 0x83, 0xdb, 0x98, 0x09,
|
||||||
|
0xfd, 0xff, 0xf6, 0xfe, 0x6f, 0x2f, 0xb4, 0x11, 0xde, 0x8f, 0x3c, 0xed, 0x20, 0xdb, 0x5c, 0x20,
|
||||||
|
0xf4, 0xf5, 0x7f, 0xff, 0x37, 0xb3, 0x9d, 0x94, 0xa5, 0x29, 0xc9, 0x4a, 0x52, 0x94, 0xa4, 0x4d,
|
||||||
|
0xff, 0xf3, 0x75, 0xe0, 0xaa, 0xe3, 0x22, 0xfb, 0x9e, 0x89, 0xbf, 0x1c, 0x4c, 0x9f, 0xff, 0xfb,
|
||||||
|
0x72, 0x76, 0xc9, 0x19, 0x67, 0xaf, 0x6f, 0x7e, 0xf7, 0xef, 0x7e, 0xf7, 0xef, 0xb6, 0x7b, 0x67,
|
||||||
|
0xb6, 0x7b, 0x67, 0xb6, 0x7b, 0x67, 0xb6, 0x78, 0x08, 0xd8, 0x96, 0x00, 0xbe, 0x62, 0xfd, 0xef,
|
||||||
|
0x7b, 0xdf, 0x2a, 0x10, 0x84, 0x21, 0x06, 0xa8, 0x0d, 0x2f, 0xff, 0x8c, 0x55, 0xb0, 0xd2, 0xf0,
|
||||||
|
0x2a, 0xe6, 0xfa, 0xe9, 0xef, 0xff, 0xe8, 0xfa, 0x01, 0x39, 0xc2, 0x9c, 0x8f, 0x09, 0xe8, 0xf5,
|
||||||
|
0x30, 0xa2, 0xaf, 0xff, 0x1f, 0x61, 0x54, 0xea, 0xd1, 0xd6, 0xde, 0xc5, 0x29, 0x4f, 0xff, 0x9e,
|
||||||
|
0xf9, 0x05, 0x2a, 0x46, 0x6f, 0x36, 0x3c, 0x0c, 0xa8, 0xb2, 0x7f, 0xff, 0x10, 0x1f, 0xff, 0x8e,
|
||||||
|
0x40, 0x9d, 0x84, 0xfb, 0x4f, 0x3f, 0xff, 0x8b, 0xc9, 0x5b, 0x85, 0x13, 0x47, 0x02, 0xc4, 0x4e,
|
||||||
|
0xf7, 0x00, 0xbd, 0x7f, 0xfd, 0xc5, 0xa0, 0xda, 0x81, 0x9a, 0x24, 0xd8, 0x3d, 0x5c, 0x15, 0x53,
|
||||||
|
0xfb, 0xbf, 0xfe, 0xf7, 0xe2, 0x20, 0x48, 0xd8, 0xf0, 0xa3, 0xa0, 0x20, 0xd8, 0x91, 0x6c, 0x02,
|
||||||
|
0xb1, 0xff, 0xf7, 0xe3, 0x2f, 0x59, 0xc2, 0x49, 0x94, 0x57, 0x95, 0xcb, 0x93, 0x3a, 0xf8, 0xff,
|
||||||
|
0xfb, 0xc1, 0x6f, 0x38, 0x3c, 0x77, 0x60, 0x88, 0x59, 0xdf, 0x60, 0xb2, 0x38, 0xe9, 0xd9, 0x00,
|
||||||
|
0x1d, 0x78, 0xe9, 0xc8, 0x3a, 0x32, 0x69, 0x9c, 0x9d, 0xb6, 0xaa, 0xbf, 0xff, 0x43, 0x59, 0x62,
|
||||||
|
0x04, 0x19, 0x30, 0xcb, 0xb7, 0xbb, 0xb8, 0xbf, 0x13, 0x3f, 0xff, 0x4d, 0xaa, 0x30, 0x0e, 0x2d,
|
||||||
|
0xd4, 0xce, 0xc1, 0x74, 0x43, 0x75, 0xd4, 0xac, 0x51, 0xff, 0xf7, 0xb5, 0x7d, 0x74, 0x83, 0xc8,
|
||||||
|
0xce, 0x4e, 0xcc, 0xce, 0xd8, 0x34, 0xc9, 0xff, 0xf7, 0x9a, 0xf2, 0xaa, 0xcb, 0x45, 0x75, 0xe2,
|
||||||
|
0x6c, 0x41, 0xf5, 0xd9, 0xe3, 0xdd, 0x2b, 0xff, 0xfe, 0x2f, 0x25, 0x6e, 0x14, 0x4d, 0x1c, 0x0b,
|
||||||
|
0x11, 0x43, 0x57, 0xff, 0x8a, 0xf6, 0xdb, 0x90, 0x47, 0xf1, 0x2c, 0x72, 0x5f, 0x12, 0xf3, 0xbf,
|
||||||
|
0xfd, 0xb6, 0x8c, 0x1f, 0x2a, 0x79, 0x43, 0xec, 0x20, 0x9c, 0xcf, 0xff, 0x6c, 0x8a, 0x5d, 0x2e,
|
||||||
|
0xe4, 0x89, 0x32, 0x9a, 0xe5, 0xa6, 0xe8, 0xab, 0xff, 0xf1, 0xaa, 0x55, 0x4e, 0x89, 0x31, 0xd5,
|
||||||
|
0x8f, 0x95, 0xfa, 0x7f, 0xf8, 0x83, 0x93, 0x10, 0xaf, 0x77, 0xdb, 0x76, 0x14, 0x41, 0x6b, 0x2f,
|
||||||
|
0xff, 0xdb, 0xb3, 0xff, 0xee, 0xc9, 0xc6, 0xfe, 0x3a, 0x41, 0x29, 0xff, 0xed, 0x04, 0xf6, 0x57,
|
||||||
|
0xff, 0xad, 0x42, 0x1c, 0xbd, 0xb4, 0x6c, 0xa7, 0x9c, 0x87, 0xff, 0xfe, 0x68, 0xd4, 0xc9, 0x92,
|
||||||
|
0xa5, 0x0d, 0x13, 0x6c, 0xdb, 0x36, 0xcd, 0xb3, 0xad, 0x6b, 0x5a, 0xd6, 0xb5, 0xad, 0x6b, 0x5a,
|
||||||
|
0xd6, 0xa0, 0xbf, 0xff, 0xe3, 0xc2, 0xd8, 0x6d, 0xe4, 0xc2, 0xf4, 0x25, 0xcb, 0x59, 0x2d, 0x72,
|
||||||
|
0x69, 0x07, 0x74, 0x08, 0x72, 0x03, 0xa8, 0x50, 0x05, 0xd6, 0xc4, 0x6f, 0x61, 0x7c, 0x5e, 0x8f,
|
||||||
|
0xb1, 0x6c, 0x68, 0x31, 0xa0, 0xc6, 0x83, 0x1a, 0x0c, 0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9b, 0x82,
|
||||||
|
0x6e, 0x09, 0xb8, 0x26, 0xe0, 0x9a, 0x13, 0x7a, 0xec, 0x03, 0x8b, 0x5c, 0xd7, 0xad, 0xfa, 0x2c,
|
||||||
|
0x33, 0xd1, 0x4e, 0x0c, 0x78, 0x31, 0xe0, 0xc7, 0x83, 0x1e, 0x10, 0x04, 0x40, 0x11, 0x00, 0x44,
|
||||||
|
0x01, 0x10, 0x04, 0x40, 0x11, 0x00, 0x43, 0x9c, 0x89, 0x17, 0xff, 0xfd, 0x9c, 0xe7, 0x03, 0xc3,
|
||||||
|
0xae, 0xde, 0x4e, 0xb4, 0xa4, 0x8b, 0xab, 0x48, 0x6a, 0xfd, 0x60, 0x7c, 0x57, 0x1f, 0xff, 0xd9,
|
||||||
|
0xb4, 0x6f, 0x2c, 0x0c, 0xc9, 0xc2, 0xcd, 0xf5, 0x60, 0x12, 0x59, 0xc5, 0xae, 0xf1, 0x27, 0x19,
|
||||||
|
0x97, 0x69, 0xa5, 0xff, 0xfe, 0xcc, 0xd6, 0x37, 0x17, 0x92, 0xb3, 0x66, 0x3f, 0xdb, 0x68, 0x44,
|
||||||
|
0x9e, 0x21, 0x12, 0x8a, 0x48, 0x6e, 0xbf, 0xff, 0xb3, 0x00, 0xda, 0x17, 0x61, 0x02, 0xfd, 0x26,
|
||||||
|
0x97, 0x1d, 0x83, 0x48, 0x87, 0x5a, 0x4e, 0xcb, 0x8f, 0x24, 0x08, 0xbf, 0xff, 0xa5, 0x31, 0x1b,
|
||||||
|
0x76, 0x35, 0x05, 0x4a, 0x6b, 0x42, 0x63, 0x65, 0x8e, 0x3d, 0x96, 0x3e, 0x36, 0xef, 0xff, 0xff,
|
||||||
|
0xe9, 0x3f, 0x11, 0xd7, 0xa0, 0x62, 0x26, 0x55, 0xb4, 0x6c, 0xb0, 0x97, 0x1f, 0x37, 0xbb, 0x6c,
|
||||||
|
0x1c, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0x34, 0xc7, 0x82, 0x42, 0x10, 0x85, 0x44, 0x21, 0x08,
|
||||||
|
0x42, 0x17, 0x27, 0xff, 0xd9, 0x5a, 0x82, 0x0e, 0xab, 0x3d, 0xa6, 0xf5, 0x32, 0x85, 0xb7, 0x7f,
|
||||||
|
0xff, 0xf8, 0xc7, 0xf1, 0x63, 0xc2, 0xcd, 0xea, 0x59, 0x25, 0x92, 0x59, 0x25, 0x92, 0x59, 0x7d,
|
||||||
|
0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x97, 0xd9, 0x7d, 0x92, 0x8d, 0xf2, 0x00, 0x48, 0xbc, 0xcb,
|
||||||
|
0x5a, 0xd6, 0xb5, 0xc0, 0xa5, 0x29, 0x4a, 0x52, 0x06, 0x77, 0xcf, 0xff, 0xb8, 0x30, 0x59, 0x27,
|
||||||
|
0xf7, 0x62, 0x38, 0x0c, 0xa1, 0x35, 0xff, 0xec, 0x9b, 0xb6, 0x5d, 0x9a, 0x02, 0x56, 0xf3, 0xc6,
|
||||||
|
0x71, 0x48, 0xbb, 0x5f, 0xfe, 0xd6, 0x42, 0xaa, 0x1d, 0x7a, 0xde, 0x77, 0x06, 0xab, 0xaf, 0xff,
|
||||||
|
0x64, 0xdd, 0xb2, 0xec, 0xd0, 0x12, 0xb7, 0x9e, 0x33, 0x80, 0x15, 0xbf, 0xfd, 0xac, 0x85, 0x54,
|
||||||
|
0x3a, 0xf5, 0xbc, 0xee, 0x0d, 0x57, 0x5f, 0xfe, 0xc9, 0xbb, 0x65, 0xd9, 0xa0, 0x25, 0x6f, 0x3c,
|
||||||
|
0x67, 0x26, 0x41, 0xff, 0xff, 0xfd, 0x21, 0x61, 0xf6, 0xe8, 0x83, 0x3c, 0x43, 0x61, 0x53, 0x6b,
|
||||||
|
0xfc, 0x33, 0xa6, 0xbe, 0x35, 0x59, 0x89, 0xbf, 0xff, 0xca, 0x91, 0xd0, 0xc1, 0x31, 0xc1, 0xe7,
|
||||||
|
0xbb, 0x89, 0xf6, 0x15, 0x87, 0x70, 0x29, 0xba, 0x64, 0xba, 0x7e, 0x88, 0xbf, 0xff, 0xb2, 0x30,
|
||||||
|
0xd1, 0x95, 0xef, 0xe1, 0xec, 0x3b, 0xef, 0xd8, 0x40, 0x72, 0x7f, 0x53, 0x27, 0xc6, 0x4c, 0xfb,
|
||||||
|
0xff, 0xfb, 0x23, 0x0d, 0x19, 0x5e, 0xfe, 0x1e, 0xc3, 0xbe, 0xfd, 0x84, 0x07, 0x27, 0xf5, 0x32,
|
||||||
|
0x7c, 0x64, 0x69, 0x54, 0x07, 0x00, 0x21, 0xa1, 0xb8, 0x31, 0xe7, 0x92, 0x4d, 0x21, 0x98, 0xe1,
|
||||||
|
0xe9, 0x7f, 0xff, 0x8d, 0x04, 0x56, 0x50, 0xf3, 0x87, 0x8e, 0xe9, 0x2c, 0x8b, 0x9a, 0x5d, 0x0e,
|
||||||
|
0x69, 0x7a, 0xdf, 0xb3, 0x5f, 0xff, 0xe3, 0x40, 0xd3, 0x1c, 0xfe, 0x0e, 0x49, 0x93, 0x22, 0xcd,
|
||||||
|
0xe3, 0xac, 0xa4, 0x02, 0xfb, 0xa5, 0x1b, 0xab, 0x9c, 0x9f, 0xff, 0xd6, 0x74, 0xad, 0x35, 0x38,
|
||||||
|
0x54, 0x07, 0xfc, 0xf4, 0x47, 0x3e, 0xc8, 0x84, 0xfb, 0x24, 0x7a, 0xe0, 0x27, 0xff, 0xf5, 0x9d,
|
||||||
|
0x13, 0x49, 0x92, 0x11, 0x5f, 0xc8, 0xd6, 0x12, 0xc1, 0xc0, 0x69, 0x64, 0x95, 0x44, 0x79, 0x86,
|
||||||
|
0x49, 0xb4, 0x9f, 0xfe, 0x3f, 0x97, 0x6d, 0x34, 0x2f, 0xd0, 0xc9, 0x77, 0xb6, 0x27, 0xff, 0x8f,
|
||||||
|
0xe5, 0xdb, 0x4d, 0x0b, 0xf4, 0x32, 0x5d, 0xe3, 0x55, 0x7d, 0xbf, 0xfd, 0x07, 0x21, 0x7d, 0xd6,
|
||||||
|
0x5c, 0x66, 0xa7, 0xd9, 0x86, 0xaf, 0xff, 0x41, 0xc8, 0x5f, 0x75, 0x97, 0x19, 0xa9, 0xf6, 0x50,
|
||||||
|
0x7a, 0x79, 0xc9, 0xff, 0xe5, 0x1b, 0x2a, 0x9d, 0xe9, 0xe9, 0x3d, 0x4b, 0x9a, 0x64, 0xff, 0xf1,
|
||||||
|
0xfc, 0xbb, 0x69, 0xa1, 0x7e, 0x86, 0x4b, 0xbc, 0x6a, 0xfa, 0x17, 0xff, 0xa4, 0xbb, 0x55, 0x2a,
|
||||||
|
0x0d, 0xeb, 0x17, 0x5e, 0x75, 0x2b, 0xff, 0xd0, 0x72, 0x17, 0xdd, 0x65, 0xc6, 0x6a, 0x7d, 0x94,
|
||||||
|
0x1c, 0x72, 0x69, 0x71, 0xaf, 0xff, 0xe8, 0xc5, 0xf4, 0xd9, 0x5a, 0x12, 0x49, 0x24, 0xda, 0x28,
|
||||||
|
0xa2, 0x8a, 0x28, 0x9d, 0x85, 0xff, 0xf9, 0x58, 0x68, 0xe1, 0xbe, 0x89, 0x87, 0x0a, 0x41, 0xbd,
|
||||||
|
0x40, 0x5a, 0xbe, 0x24, 0x00, 0xa4, 0xd7, 0xb7, 0x6b, 0xc7, 0x0b, 0x74, 0x72, 0x39, 0x7e, 0x97,
|
||||||
|
0xe9, 0x7e, 0x97, 0xe9, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe8, 0x8e, 0x88, 0xe7, 0xf1,
|
||||||
|
0x59, 0x7f, 0xff, 0xf5, 0x72, 0xea, 0x9c, 0xa5, 0xe0, 0xe5, 0xe5, 0x3e, 0x53, 0xe5, 0x3e, 0x53,
|
||||||
|
0xe5, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x5d, 0x05, 0xd0, 0x54, 0x5d, 0x80, 0x5f, 0xff,
|
||||||
|
0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98, 0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xe9, 0xaf, 0xff, 0xca, 0xc3,
|
||||||
|
0x47, 0x0d, 0xf4, 0x4c, 0x38, 0x52, 0x0d, 0xea, 0x02, 0xd6, 0x96, 0x75, 0xfb, 0xff, 0xf4, 0xf5,
|
||||||
|
0xfe, 0xc9, 0x8b, 0xed, 0xa0, 0xd4, 0x3f, 0xd4, 0x4e, 0xf3, 0xbb, 0xff, 0xf4, 0xf2, 0xb7, 0x3b,
|
||||||
|
0x35, 0x34, 0x01, 0xbd, 0xcf, 0xc7, 0x6b, 0xc8, 0x86, 0xe2, 0xdf, 0xff, 0x95, 0xa6, 0x26, 0x6d,
|
||||||
|
0x66, 0xef, 0x24, 0x4f, 0x9c, 0x50, 0x2e, 0x0b, 0x5f, 0xff, 0x95, 0x86, 0x8e, 0x1b, 0xe8, 0x98,
|
||||||
|
0x70, 0xa4, 0x1b, 0xd4, 0x05, 0xad, 0x73, 0x9c, 0xf8, 0xd7, 0xff, 0xe5, 0x69, 0x89, 0x9b, 0x5a,
|
||||||
|
0xd6, 0xc7, 0xef, 0x7b, 0xde, 0xf7, 0x16, 0xbf, 0xfd, 0x93, 0xe0, 0xd5, 0x22, 0xf4, 0x00, 0x3c,
|
||||||
|
0x0c, 0x4d, 0xbe, 0x7f, 0xff, 0xfd, 0x40, 0x7a, 0x70, 0x28, 0xb4, 0x34, 0xd9, 0x35, 0x93, 0x59,
|
||||||
|
0x35, 0x93, 0x59, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x15, 0x71, 0x57, 0x13, 0x6a, 0x84,
|
||||||
|
0x01, 0x71, 0x0e, 0xa9, 0x4a, 0x52, 0x95, 0x06, 0xb5, 0xad, 0x6b, 0x51, 0x99, 0x0b, 0xdf, 0xfe,
|
||||||
|
0x83, 0xd1, 0xc1, 0x15, 0xe1, 0x93, 0x97, 0xb2, 0x8b, 0xd5, 0xff, 0xe8, 0x3d, 0x1c, 0x11, 0x5e,
|
||||||
|
0x19, 0x39, 0x7b, 0x28, 0x32, 0x5b, 0xf5, 0x6f, 0xff, 0x84, 0x61, 0x55, 0x1a, 0xb6, 0xa2, 0x05,
|
||||||
|
0x17, 0xea, 0x77, 0xff, 0xbf, 0xa1, 0xda, 0x6b, 0x8b, 0x14, 0x60, 0xb1, 0x38, 0x81, 0x17, 0x97,
|
||||||
|
0xff, 0xb5, 0x90, 0xaa, 0x87, 0x5e, 0xb7, 0x9d, 0xc1, 0x9a, 0xeb, 0xff, 0xd9, 0x3e, 0x0d, 0x52,
|
||||||
|
0x2f, 0x40, 0x03, 0xc0, 0xc4, 0xe5, 0x54, 0xa6, 0xdf, 0xff, 0xa7, 0x95, 0xb9, 0xd9, 0xa9, 0xa0,
|
||||||
|
0x0d, 0xee, 0x7e, 0x3b, 0x5e, 0x72, 0xef, 0xff, 0xd3, 0xca, 0xdc, 0xec, 0xd4, 0xd0, 0x06, 0xf7,
|
||||||
|
0x3f, 0x1d, 0xaf, 0x22, 0x29, 0xf5, 0x7f, 0xfe, 0x56, 0x1a, 0x38, 0x6f, 0xa2, 0x61, 0xc2, 0x90,
|
||||||
|
0x6f, 0x50, 0x17, 0xa6, 0xbf, 0xff, 0x2b, 0x0d, 0x1c, 0x37, 0xd1, 0x30, 0xe1, 0x48, 0x37, 0xa8,
|
||||||
|
0x0b, 0x55, 0x39, 0xaf, 0xc0, 0x10, 0x61, 0x2d, 0x46, 0x41, 0x11, 0x62, 0x85, 0x8e, 0x38, 0x4f,
|
||||||
|
0x9f, 0xff, 0xa0, 0xec, 0x48, 0x58, 0xc2, 0x3d, 0xd1, 0xc3, 0x19, 0xc3, 0x9c, 0x10, 0x5f, 0xff,
|
||||||
|
0x9a, 0x0a, 0x59, 0x0b, 0x58, 0xda, 0x57, 0xf6, 0xb4, 0x6d, 0xdb, 0xd2, 0xd0, 0x74, 0xff, 0xfb,
|
||||||
|
0x6e, 0x83, 0xdb, 0x08, 0x11, 0x73, 0xd9, 0x78, 0x19, 0x7e, 0x5b, 0xa4, 0xff, 0xfb, 0x6d, 0x28,
|
||||||
|
0x6c, 0x1c, 0x47, 0xe7, 0xaa, 0x54, 0x14, 0x29, 0x10, 0x54, 0x84, 0xaf, 0x1f, 0xfe, 0x55, 0xcd,
|
||||||
|
0xa1, 0xdc, 0xc5, 0x8c, 0x5f, 0x8b, 0xa9, 0x49, 0xff, 0xe5, 0x5c, 0xda, 0x1d, 0xcc, 0x58, 0xc5,
|
||||||
|
0xf8, 0xb9, 0xf9, 0xf4, 0xab, 0xff, 0xda, 0xe2, 0xc5, 0x4b, 0x14, 0xd2, 0x8e, 0xe6, 0xbb, 0x98,
|
||||||
|
0xbf, 0xfd, 0xae, 0x2c, 0x54, 0xb1, 0x4d, 0x28, 0xee, 0x6b, 0xab, 0xd2, 0x09, 0xed, 0xff, 0xee,
|
||||||
|
0x2e, 0x2a, 0xa2, 0x37, 0x77, 0x7d, 0xbd, 0x0c, 0x42, 0xff, 0xf6, 0xb8, 0xb1, 0x52, 0xc5, 0x34,
|
||||||
|
0xa3, 0xb9, 0xae, 0xae, 0xd0, 0x74, 0xff, 0xf3, 0x35, 0xea, 0xa4, 0x9a, 0x1c, 0x9b, 0xe5, 0xac,
|
||||||
|
0xa2, 0x7f, 0xf9, 0x57, 0x36, 0x87, 0x73, 0x16, 0x31, 0x7e, 0x2e, 0x7e, 0xd0, 0x50,
|
||||||
|
]
|
||||||
|
|
||||||
|
static let bars709Full: [UInt8] = [
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x40, 0x01, 0x0c, 0x01, 0xff, 0xff, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00,
|
||||||
|
0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03, 0x00, 0xff, 0x95, 0x98, 0x09, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x42, 0x01, 0x01, 0x01, 0x60, 0x00, 0x00, 0x03, 0x00, 0x90, 0x00, 0x00, 0x03, 0x00, 0x00, 0x03,
|
||||||
|
0x00, 0xff, 0xa0, 0x08, 0x08, 0x10, 0x59, 0x65, 0x66, 0x92, 0x4c, 0xae, 0x6e, 0x02, 0x02, 0x02,
|
||||||
|
0x08, 0x00, 0x00, 0x03, 0x00, 0x08, 0x00, 0x00, 0x03, 0x00, 0xc8, 0x40, 0x00, 0x00, 0x00, 0x01,
|
||||||
|
0x44, 0x01, 0xc1, 0x71, 0xa9, 0x12, 0x00, 0x00, 0x01, 0x4e, 0x01, 0x05, 0xff, 0xff, 0xff, 0xff,
|
||||||
|
0xff, 0xff, 0xff, 0xff, 0xd1, 0x2c, 0xa2, 0xde, 0x09, 0xb5, 0x17, 0x47, 0xdb, 0xbb, 0x55, 0xa4,
|
||||||
|
0xfe, 0x7f, 0xc2, 0xfc, 0x4e, 0x78, 0x32, 0x36, 0x35, 0x20, 0x28, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||||
|
0x20, 0x32, 0x31, 0x36, 0x29, 0x20, 0x2d, 0x20, 0x34, 0x2e, 0x32, 0x2b, 0x31, 0x2d, 0x65, 0x34,
|
||||||
|
0x34, 0x34, 0x37, 0x34, 0x34, 0x3a, 0x5b, 0x4d, 0x61, 0x63, 0x20, 0x4f, 0x53, 0x20, 0x58, 0x5d,
|
||||||
|
0x5b, 0x63, 0x6c, 0x61, 0x6e, 0x67, 0x20, 0x32, 0x31, 0x2e, 0x30, 0x2e, 0x30, 0x5d, 0x5b, 0x36,
|
||||||
|
0x34, 0x20, 0x62, 0x69, 0x74, 0x5d, 0x20, 0x38, 0x62, 0x69, 0x74, 0x2b, 0x31, 0x30, 0x62, 0x69,
|
||||||
|
0x74, 0x2b, 0x31, 0x32, 0x62, 0x69, 0x74, 0x20, 0x2d, 0x20, 0x48, 0x2e, 0x32, 0x36, 0x35, 0x2f,
|
||||||
|
0x48, 0x45, 0x56, 0x43, 0x20, 0x63, 0x6f, 0x64, 0x65, 0x63, 0x20, 0x2d, 0x20, 0x43, 0x6f, 0x70,
|
||||||
|
0x79, 0x72, 0x69, 0x67, 0x68, 0x74, 0x20, 0x32, 0x30, 0x31, 0x33, 0x2d, 0x32, 0x30, 0x31, 0x38,
|
||||||
|
0x20, 0x28, 0x63, 0x29, 0x20, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x63, 0x6f, 0x72, 0x65, 0x77, 0x61,
|
||||||
|
0x72, 0x65, 0x2c, 0x20, 0x49, 0x6e, 0x63, 0x20, 0x2d, 0x20, 0x68, 0x74, 0x74, 0x70, 0x3a, 0x2f,
|
||||||
|
0x2f, 0x78, 0x32, 0x36, 0x35, 0x2e, 0x6f, 0x72, 0x67, 0x20, 0x2d, 0x20, 0x6f, 0x70, 0x74, 0x69,
|
||||||
|
0x6f, 0x6e, 0x73, 0x3a, 0x20, 0x63, 0x70, 0x75, 0x69, 0x64, 0x3d, 0x39, 0x38, 0x20, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x74, 0x68, 0x72, 0x65, 0x61, 0x64, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x77, 0x70, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x6d, 0x6f, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x70, 0x6d, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x70, 0x73, 0x6e, 0x72, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x73, 0x69, 0x6d, 0x20, 0x6c, 0x6f, 0x67, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30,
|
||||||
|
0x20, 0x62, 0x69, 0x74, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x38, 0x20, 0x69, 0x6e, 0x70, 0x75,
|
||||||
|
0x74, 0x2d, 0x63, 0x73, 0x70, 0x3d, 0x31, 0x20, 0x66, 0x70, 0x73, 0x3d, 0x32, 0x35, 0x2f, 0x31,
|
||||||
|
0x20, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x2d, 0x72, 0x65, 0x73, 0x3d, 0x32, 0x35, 0x36, 0x78, 0x36,
|
||||||
|
0x34, 0x20, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6c, 0x61, 0x63, 0x65, 0x3d, 0x30, 0x20, 0x74, 0x6f,
|
||||||
|
0x74, 0x61, 0x6c, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x2d, 0x69, 0x64, 0x63, 0x3d, 0x30, 0x20, 0x68, 0x69, 0x67, 0x68, 0x2d, 0x74, 0x69,
|
||||||
|
0x65, 0x72, 0x3d, 0x31, 0x20, 0x75, 0x68, 0x64, 0x2d, 0x62, 0x64, 0x3d, 0x30, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x3d, 0x33, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x2d, 0x6e, 0x6f, 0x6e,
|
||||||
|
0x2d, 0x63, 0x6f, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x20, 0x72, 0x65, 0x70,
|
||||||
|
0x65, 0x61, 0x74, 0x2d, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x20, 0x61, 0x6e, 0x6e, 0x65,
|
||||||
|
0x78, 0x62, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x75, 0x64, 0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x62,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x65, 0x6f, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x72, 0x64, 0x20, 0x69,
|
||||||
|
0x6e, 0x66, 0x6f, 0x20, 0x68, 0x61, 0x73, 0x68, 0x3d, 0x30, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f,
|
||||||
|
0x72, 0x61, 0x6c, 0x2d, 0x6c, 0x61, 0x79, 0x65, 0x72, 0x73, 0x3d, 0x30, 0x20, 0x6f, 0x70, 0x65,
|
||||||
|
0x6e, 0x2d, 0x67, 0x6f, 0x70, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74,
|
||||||
|
0x3d, 0x32, 0x35, 0x20, 0x6b, 0x65, 0x79, 0x69, 0x6e, 0x74, 0x3d, 0x32, 0x35, 0x30, 0x20, 0x67,
|
||||||
|
0x6f, 0x70, 0x2d, 0x6c, 0x6f, 0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x66, 0x72, 0x61, 0x6d, 0x65, 0x73, 0x3d, 0x34, 0x20, 0x62, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74,
|
||||||
|
0x3d, 0x32, 0x20, 0x62, 0x2d, 0x70, 0x79, 0x72, 0x61, 0x6d, 0x69, 0x64, 0x20, 0x62, 0x66, 0x72,
|
||||||
|
0x61, 0x6d, 0x65, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x20, 0x72, 0x63, 0x2d, 0x6c, 0x6f,
|
||||||
|
0x6f, 0x6b, 0x61, 0x68, 0x65, 0x61, 0x64, 0x3d, 0x32, 0x30, 0x20, 0x6c, 0x6f, 0x6f, 0x6b, 0x61,
|
||||||
|
0x68, 0x65, 0x61, 0x64, 0x2d, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x30, 0x20, 0x73, 0x63,
|
||||||
|
0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x3d, 0x34, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x69, 0x73,
|
||||||
|
0x74, 0x2d, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75, 0x74, 0x20, 0x72, 0x61, 0x64, 0x6c, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x70, 0x6c, 0x69, 0x63, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x69,
|
||||||
|
0x6e, 0x74, 0x72, 0x61, 0x2d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x20, 0x63, 0x74, 0x75,
|
||||||
|
0x3d, 0x36, 0x34, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x63, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d,
|
||||||
|
0x38, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x65, 0x63, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x6d, 0x70,
|
||||||
|
0x20, 0x6d, 0x61, 0x78, 0x2d, 0x74, 0x75, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x33, 0x32, 0x20,
|
||||||
|
0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d, 0x31,
|
||||||
|
0x20, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x3d,
|
||||||
|
0x31, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x74, 0x75, 0x3d, 0x30, 0x20, 0x72, 0x64, 0x6f,
|
||||||
|
0x71, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69,
|
||||||
|
0x63, 0x2d, 0x72, 0x64, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x73, 0x69,
|
||||||
|
0x6d, 0x2d, 0x72, 0x64, 0x20, 0x73, 0x69, 0x67, 0x6e, 0x68, 0x69, 0x64, 0x65, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x3d,
|
||||||
|
0x30, 0x20, 0x6e, 0x72, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x72, 0x61, 0x69, 0x6e, 0x65, 0x64, 0x2d, 0x69, 0x6e, 0x74, 0x72,
|
||||||
|
0x61, 0x20, 0x73, 0x74, 0x72, 0x6f, 0x6e, 0x67, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x2d, 0x73,
|
||||||
|
0x6d, 0x6f, 0x6f, 0x74, 0x68, 0x69, 0x6e, 0x67, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6d, 0x65, 0x72,
|
||||||
|
0x67, 0x65, 0x3d, 0x33, 0x20, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x72, 0x65, 0x66, 0x73, 0x3d,
|
||||||
|
0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x73,
|
||||||
|
0x20, 0x6d, 0x65, 0x3d, 0x31, 0x20, 0x73, 0x75, 0x62, 0x6d, 0x65, 0x3d, 0x32, 0x20, 0x6d, 0x65,
|
||||||
|
0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x35, 0x37, 0x20, 0x74, 0x65, 0x6d, 0x70, 0x6f, 0x72, 0x61,
|
||||||
|
0x6c, 0x2d, 0x6d, 0x76, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x64,
|
||||||
|
0x75, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x6d, 0x65, 0x20, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74,
|
||||||
|
0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x77, 0x65, 0x69, 0x67, 0x68, 0x74, 0x62, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x2d, 0x73, 0x72, 0x63, 0x2d, 0x70, 0x69, 0x63, 0x73,
|
||||||
|
0x20, 0x64, 0x65, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x3d, 0x30, 0x3a, 0x30, 0x20, 0x73, 0x61, 0x6f,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x61, 0x6f, 0x2d, 0x6e, 0x6f, 0x6e, 0x2d, 0x64, 0x65, 0x62, 0x6c,
|
||||||
|
0x6f, 0x63, 0x6b, 0x20, 0x72, 0x64, 0x3d, 0x33, 0x20, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x69,
|
||||||
|
0x76, 0x65, 0x2d, 0x73, 0x61, 0x6f, 0x3d, 0x34, 0x20, 0x65, 0x61, 0x72, 0x6c, 0x79, 0x2d, 0x73,
|
||||||
|
0x6b, 0x69, 0x70, 0x20, 0x72, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x61, 0x73,
|
||||||
|
0x74, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d, 0x74, 0x73, 0x6b, 0x69, 0x70,
|
||||||
|
0x2d, 0x66, 0x61, 0x73, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x2d, 0x6c, 0x6f, 0x73, 0x73,
|
||||||
|
0x6c, 0x65, 0x73, 0x73, 0x20, 0x62, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x73, 0x70, 0x6c, 0x69, 0x74, 0x72, 0x64, 0x2d, 0x73, 0x6b, 0x69, 0x70, 0x20, 0x72, 0x64, 0x70,
|
||||||
|
0x65, 0x6e, 0x61, 0x6c, 0x74, 0x79, 0x3d, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x3d,
|
||||||
|
0x32, 0x2e, 0x30, 0x30, 0x20, 0x70, 0x73, 0x79, 0x2d, 0x72, 0x64, 0x6f, 0x71, 0x3d, 0x30, 0x2e,
|
||||||
|
0x30, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x72, 0x64, 0x2d, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20,
|
||||||
|
0x6c, 0x6f, 0x73, 0x73, 0x6c, 0x65, 0x73, 0x73, 0x20, 0x63, 0x62, 0x71, 0x70, 0x6f, 0x66, 0x66,
|
||||||
|
0x73, 0x3d, 0x30, 0x20, 0x63, 0x72, 0x71, 0x70, 0x6f, 0x66, 0x66, 0x73, 0x3d, 0x30, 0x20, 0x72,
|
||||||
|
0x63, 0x3d, 0x63, 0x71, 0x70, 0x20, 0x71, 0x70, 0x3d, 0x34, 0x20, 0x69, 0x70, 0x72, 0x61, 0x74,
|
||||||
|
0x69, 0x6f, 0x3d, 0x31, 0x2e, 0x34, 0x30, 0x20, 0x70, 0x62, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x3d,
|
||||||
|
0x31, 0x2e, 0x33, 0x30, 0x20, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x64, 0x65, 0x3d, 0x30, 0x20, 0x61,
|
||||||
|
0x71, 0x2d, 0x73, 0x74, 0x72, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x3d, 0x30, 0x2e, 0x30, 0x30, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x63, 0x75, 0x74, 0x72, 0x65, 0x65, 0x20, 0x7a, 0x6f, 0x6e, 0x65, 0x2d, 0x63,
|
||||||
|
0x6f, 0x75, 0x6e, 0x74, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74,
|
||||||
|
0x2d, 0x63, 0x62, 0x72, 0x20, 0x71, 0x67, 0x2d, 0x73, 0x69, 0x7a, 0x65, 0x3d, 0x36, 0x34, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x72, 0x63, 0x2d, 0x67, 0x72, 0x61, 0x69, 0x6e, 0x20, 0x71, 0x70, 0x6d, 0x61,
|
||||||
|
0x78, 0x3d, 0x36, 0x39, 0x20, 0x71, 0x70, 0x6d, 0x69, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d,
|
||||||
|
0x63, 0x6f, 0x6e, 0x73, 0x74, 0x2d, 0x76, 0x62, 0x76, 0x20, 0x73, 0x61, 0x72, 0x3d, 0x30, 0x20,
|
||||||
|
0x6f, 0x76, 0x65, 0x72, 0x73, 0x63, 0x61, 0x6e, 0x3d, 0x30, 0x20, 0x76, 0x69, 0x64, 0x65, 0x6f,
|
||||||
|
0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x3d, 0x35, 0x20, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x3d, 0x31,
|
||||||
|
0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x70, 0x72, 0x69, 0x6d, 0x3d, 0x31, 0x20, 0x74, 0x72, 0x61,
|
||||||
|
0x6e, 0x73, 0x66, 0x65, 0x72, 0x3d, 0x31, 0x20, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x6d, 0x61, 0x74,
|
||||||
|
0x72, 0x69, 0x78, 0x3d, 0x31, 0x20, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x61, 0x6c, 0x6f, 0x63, 0x3d,
|
||||||
|
0x30, 0x20, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||||
|
0x3d, 0x30, 0x20, 0x63, 0x6c, 0x6c, 0x3d, 0x30, 0x2c, 0x30, 0x20, 0x6d, 0x69, 0x6e, 0x2d, 0x6c,
|
||||||
|
0x75, 0x6d, 0x61, 0x3d, 0x30, 0x20, 0x6d, 0x61, 0x78, 0x2d, 0x6c, 0x75, 0x6d, 0x61, 0x3d, 0x32,
|
||||||
|
0x35, 0x35, 0x20, 0x6c, 0x6f, 0x67, 0x32, 0x2d, 0x6d, 0x61, 0x78, 0x2d, 0x70, 0x6f, 0x63, 0x2d,
|
||||||
|
0x6c, 0x73, 0x62, 0x3d, 0x38, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x74, 0x69, 0x6d, 0x69, 0x6e, 0x67,
|
||||||
|
0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x20, 0x76, 0x75, 0x69, 0x2d, 0x68, 0x72, 0x64, 0x2d, 0x69, 0x6e,
|
||||||
|
0x66, 0x6f, 0x20, 0x73, 0x6c, 0x69, 0x63, 0x65, 0x73, 0x3d, 0x31, 0x20, 0x6e, 0x6f, 0x2d, 0x6f,
|
||||||
|
0x70, 0x74, 0x2d, 0x71, 0x70, 0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6f, 0x70, 0x74,
|
||||||
|
0x2d, 0x72, 0x65, 0x66, 0x2d, 0x6c, 0x69, 0x73, 0x74, 0x2d, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68,
|
||||||
|
0x2d, 0x70, 0x70, 0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61,
|
||||||
|
0x73, 0x73, 0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x72, 0x70, 0x73, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65,
|
||||||
|
0x63, 0x75, 0x74, 0x2d, 0x62, 0x69, 0x61, 0x73, 0x3d, 0x30, 0x2e, 0x30, 0x35, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6f, 0x70, 0x74, 0x2d, 0x63, 0x75, 0x2d, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x2d, 0x71, 0x70,
|
||||||
|
0x20, 0x6e, 0x6f, 0x2d, 0x61, 0x71, 0x2d, 0x6d, 0x6f, 0x74, 0x69, 0x6f, 0x6e, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d,
|
||||||
|
0x6f, 0x70, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x68, 0x64, 0x72, 0x31, 0x30, 0x2d, 0x6f, 0x70,
|
||||||
|
0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x69, 0x64, 0x72, 0x2d, 0x72, 0x65, 0x63, 0x6f, 0x76, 0x65, 0x72,
|
||||||
|
0x79, 0x2d, 0x73, 0x65, 0x69, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x72,
|
||||||
|
0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61,
|
||||||
|
0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x73, 0x61, 0x76, 0x65, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65,
|
||||||
|
0x2d, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x3d, 0x30, 0x20, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
|
||||||
|
0x73, 0x2d, 0x6c, 0x6f, 0x61, 0x64, 0x2d, 0x72, 0x65, 0x75, 0x73, 0x65, 0x2d, 0x6c, 0x65, 0x76,
|
||||||
|
0x65, 0x6c, 0x3d, 0x30, 0x20, 0x73, 0x63, 0x61, 0x6c, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f,
|
||||||
|
0x72, 0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x72, 0x61,
|
||||||
|
0x3d, 0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x3d,
|
||||||
|
0x30, 0x20, 0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x2d, 0x6d, 0x76, 0x3d, 0x31, 0x20, 0x72, 0x65,
|
||||||
|
0x66, 0x69, 0x6e, 0x65, 0x2d, 0x63, 0x74, 0x75, 0x2d, 0x64, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x74,
|
||||||
|
0x69, 0x6f, 0x6e, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x6c, 0x69, 0x6d, 0x69, 0x74, 0x2d, 0x73,
|
||||||
|
0x61, 0x6f, 0x20, 0x63, 0x74, 0x75, 0x2d, 0x69, 0x6e, 0x66, 0x6f, 0x3d, 0x30, 0x20, 0x6e, 0x6f,
|
||||||
|
0x2d, 0x6c, 0x6f, 0x77, 0x70, 0x61, 0x73, 0x73, 0x2d, 0x64, 0x63, 0x74, 0x20, 0x72, 0x65, 0x66,
|
||||||
|
0x69, 0x6e, 0x65, 0x2d, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2d, 0x74, 0x79, 0x70,
|
||||||
|
0x65, 0x3d, 0x30, 0x20, 0x63, 0x6f, 0x70, 0x79, 0x2d, 0x70, 0x69, 0x63, 0x3d, 0x31, 0x20, 0x6d,
|
||||||
|
0x61, 0x78, 0x2d, 0x61, 0x75, 0x73, 0x69, 0x7a, 0x65, 0x2d, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||||
|
0x3d, 0x31, 0x2e, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x64, 0x79, 0x6e, 0x61, 0x6d, 0x69, 0x63, 0x2d,
|
||||||
|
0x72, 0x65, 0x66, 0x69, 0x6e, 0x65, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65,
|
||||||
|
0x2d, 0x73, 0x65, 0x69, 0x20, 0x6e, 0x6f, 0x2d, 0x68, 0x65, 0x76, 0x63, 0x2d, 0x61, 0x71, 0x20,
|
||||||
|
0x6e, 0x6f, 0x2d, 0x73, 0x76, 0x74, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x20,
|
||||||
|
0x71, 0x70, 0x2d, 0x61, 0x64, 0x61, 0x70, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x72, 0x61,
|
||||||
|
0x6e, 0x67, 0x65, 0x3d, 0x31, 0x2e, 0x30, 0x30, 0x20, 0x73, 0x63, 0x65, 0x6e, 0x65, 0x63, 0x75,
|
||||||
|
0x74, 0x2d, 0x61, 0x77, 0x61, 0x72, 0x65, 0x2d, 0x71, 0x70, 0x3d, 0x30, 0x63, 0x6f, 0x6e, 0x66,
|
||||||
|
0x6f, 0x72, 0x6d, 0x61, 0x6e, 0x63, 0x65, 0x2d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x2d, 0x6f,
|
||||||
|
0x66, 0x66, 0x73, 0x65, 0x74, 0x73, 0x20, 0x72, 0x69, 0x67, 0x68, 0x74, 0x3d, 0x30, 0x20, 0x62,
|
||||||
|
0x6f, 0x74, 0x74, 0x6f, 0x6d, 0x3d, 0x30, 0x20, 0x64, 0x65, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2d,
|
||||||
|
0x6d, 0x61, 0x78, 0x2d, 0x72, 0x61, 0x74, 0x65, 0x3d, 0x30, 0x20, 0x6e, 0x6f, 0x2d, 0x76, 0x62,
|
||||||
|
0x76, 0x2d, 0x6c, 0x69, 0x76, 0x65, 0x2d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x2d, 0x70, 0x61, 0x73,
|
||||||
|
0x73, 0x20, 0x6e, 0x6f, 0x2d, 0x6d, 0x63, 0x73, 0x74, 0x66, 0x20, 0x6e, 0x6f, 0x2d, 0x73, 0x62,
|
||||||
|
0x72, 0x63, 0x20, 0x6e, 0x6f, 0x2d, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x2d, 0x72, 0x63, 0x80, 0x00,
|
||||||
|
0x00, 0x01, 0x28, 0x01, 0xaf, 0x05, 0xb8, 0x10, 0x0c, 0x7f, 0x80, 0xd7, 0x00, 0xc4, 0xca, 0xe6,
|
||||||
|
0x94, 0x42, 0x38, 0x60, 0x38, 0x52, 0x45, 0x24, 0x52, 0x45, 0x24, 0x55, 0x59, 0x55, 0x95, 0x59,
|
||||||
|
0x55, 0x95, 0x59, 0x55, 0x95, 0x59, 0x56, 0xb4, 0xcb, 0x51, 0xc8, 0xd6, 0xa0, 0x35, 0xd5, 0x55,
|
||||||
|
0xa4, 0x40, 0xd9, 0x57, 0x3f, 0xff, 0x91, 0xc3, 0x2c, 0x30, 0xc3, 0x0c, 0x31, 0x1c, 0x71, 0xc7,
|
||||||
|
0x1c, 0x71, 0xc7, 0x85, 0x3f, 0xff, 0xda, 0x86, 0xda, 0x58, 0x75, 0x4a, 0x3b, 0xc1, 0xa2, 0x82,
|
||||||
|
0x3d, 0xff, 0xff, 0x22, 0x76, 0xbf, 0xff, 0xfc, 0xd8, 0x79, 0x99, 0x24, 0xe8, 0x0f, 0x75, 0xff,
|
||||||
|
0x5f, 0xf5, 0xff, 0x5f, 0xf6, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe2, 0xee, 0x2e, 0xe0,
|
||||||
|
0x9c, 0xff, 0xf6, 0x3d, 0xd0, 0x03, 0xeb, 0xce, 0x73, 0x9c, 0xe7, 0x3a, 0xd6, 0xb5, 0xad, 0x6b,
|
||||||
|
0x4c, 0xf4, 0xa0, 0x03, 0x93, 0xff, 0xe6, 0x75, 0xd0, 0x3d, 0xcf, 0xf0, 0x4e, 0xd2, 0x72, 0x8d,
|
||||||
|
0x74, 0x7f, 0xfe, 0x89, 0x44, 0x87, 0x74, 0x94, 0xe6, 0x55, 0x90, 0x59, 0x45, 0xa5, 0x9a, 0x2e,
|
||||||
|
0x4f, 0x7f, 0xfc, 0xeb, 0x9a, 0x21, 0xb2, 0xc8, 0x3c, 0x30, 0xf4, 0x24, 0x7a, 0x3f, 0xff, 0x44,
|
||||||
|
0xa2, 0x43, 0xba, 0x4a, 0x73, 0x2a, 0xc8, 0x2c, 0x9a, 0x79, 0xa3, 0xe3, 0xff, 0xeb, 0x8f, 0x95,
|
||||||
|
0x52, 0x72, 0x52, 0x34, 0xc8, 0xe9, 0x8b, 0x44, 0xff, 0xf6, 0xf0, 0x3a, 0x83, 0x7c, 0x5c, 0xce,
|
||||||
|
0xd4, 0xba, 0x89, 0x3f, 0x2b, 0x0c, 0x90, 0x70, 0xa0, 0x00, 0xf0, 0x2c, 0x88, 0xe9, 0x8e, 0x1f,
|
||||||
|
0xe0, 0x06, 0x79, 0xa1, 0x79, 0x80, 0x12, 0x3f, 0xff, 0x7d, 0xbc, 0x64, 0xb9, 0x97, 0xb8, 0xe8,
|
||||||
|
0x1b, 0x03, 0xeb, 0x6f, 0xff, 0xca, 0xe9, 0xb5, 0x52, 0xa6, 0x29, 0x29, 0xfc, 0xfb, 0x8c, 0x40,
|
||||||
|
0x1c, 0x5f, 0xff, 0x35, 0x77, 0x52, 0xf6, 0x79, 0xec, 0xa4, 0x7e, 0x2a, 0xdf, 0xaf, 0xff, 0x81,
|
||||||
|
0xc0, 0x3b, 0x0b, 0x8b, 0x27, 0xcf, 0x6a, 0xd7, 0x4c, 0xec, 0x11, 0x59, 0xff, 0xf6, 0xe2, 0x9f,
|
||||||
|
0xfe, 0xb6, 0x70, 0xc4, 0x83, 0x1c, 0x05, 0xbb, 0xff, 0xf0, 0x0d, 0x33, 0xc8, 0xb1, 0xeb, 0x05,
|
||||||
|
0x9f, 0x5c, 0xc7, 0x20, 0xdf, 0x8f, 0xff, 0x67, 0x88, 0xff, 0xea, 0x77, 0x5e, 0x4b, 0x79, 0xd4,
|
||||||
|
0xd2, 0x3f, 0xfe, 0xc4, 0x27, 0x79, 0x7b, 0xdc, 0x96, 0x40, 0xcd, 0xc7, 0x6f, 0xa8, 0x0a, 0x28,
|
||||||
|
0xcf, 0xff, 0xfe, 0xef, 0xdb, 0xd4, 0xd0, 0x3c, 0x78, 0xf1, 0xe3, 0xd0, 0x20, 0x40, 0x81, 0x02,
|
||||||
|
0x04, 0x07, 0x43, 0xff, 0xff, 0x55, 0x80, 0x26, 0x9f, 0xe0, 0xb3, 0x64, 0xa3, 0x87, 0x88, 0x9c,
|
||||||
|
0xee, 0x95, 0x9f, 0xda, 0x98, 0x80, 0x2c, 0xf5, 0xd9, 0x3a, 0xe6, 0xfa, 0xd0, 0x75, 0x46, 0xd0,
|
||||||
|
0x2d, 0x02, 0xd0, 0x2d, 0x02, 0xd0, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1, 0x0e, 0x10, 0xe1,
|
||||||
|
0x03, 0xb7, 0xf9, 0xaf, 0xdf, 0xff, 0xfc, 0x4a, 0xae, 0x23, 0x6e, 0x1b, 0xcb, 0xd4, 0x2e, 0x02,
|
||||||
|
0xe0, 0x2e, 0x02, 0xe0, 0x2e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xef, 0x6e, 0xf6, 0xe7,
|
||||||
|
0xc8, 0x1b, 0x7f, 0xff, 0xbb, 0x0c, 0x4b, 0xdb, 0x02, 0x66, 0xaa, 0x94, 0xf4, 0xed, 0xd6, 0x0d,
|
||||||
|
0xd4, 0x3f, 0xff, 0xb1, 0xa5, 0x3e, 0xed, 0x3c, 0xe9, 0x07, 0xa9, 0x44, 0xdf, 0xb9, 0x10, 0x00,
|
||||||
|
0x34, 0x32, 0x13, 0xff, 0xef, 0x84, 0xe7, 0xf8, 0x61, 0x80, 0x24, 0xdf, 0xcb, 0x21, 0xe5, 0xb1,
|
||||||
|
0x52, 0x09, 0xff, 0xfa, 0xbb, 0x0c, 0x7c, 0xe1, 0xdb, 0x30, 0xf6, 0x95, 0x55, 0xf5, 0x15, 0xdf,
|
||||||
|
0x61, 0x17, 0x9f, 0xff, 0x9d, 0xf9, 0xe9, 0x79, 0xe7, 0x7b, 0x14, 0x7a, 0x36, 0x0d, 0x1b, 0xc7,
|
||||||
|
0x1c, 0xf7, 0xff, 0xe1, 0xfd, 0x7f, 0x44, 0xd8, 0x9b, 0xcf, 0x7a, 0x6e, 0x17, 0x38, 0xcc, 0x8c,
|
||||||
|
0xc2, 0x0f, 0x4f, 0x57, 0xff, 0xf7, 0x55, 0x2b, 0x2a, 0xaa, 0xaa, 0xaa, 0xb2, 0x79, 0xe7, 0x9e,
|
||||||
|
0x79, 0xe7, 0x59, 0xbf, 0xfe, 0xe7, 0x49, 0x8d, 0x06, 0x01, 0xfc, 0x41, 0x0b, 0xa7, 0x35, 0xc7,
|
||||||
|
0x13, 0x27, 0xff, 0xff, 0x14, 0x1e, 0x21, 0x48, 0x15, 0xf9, 0xbc, 0xbd, 0xcb, 0xdc, 0xbd, 0xcb,
|
||||||
|
0xdc, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xac, 0xba, 0xcb, 0xab, 0xe2, 0x36, 0x25, 0x80,
|
||||||
|
0x2f, 0xfb, 0x08, 0xc6, 0x31, 0x8c, 0x6f, 0xad, 0x6b, 0x5a, 0xd6, 0x36, 0x03, 0x4b, 0xff, 0xeb,
|
||||||
|
0xc5, 0x63, 0x41, 0x65, 0x0a, 0x0c, 0x08, 0x18, 0xba, 0xd7, 0xff, 0xf7, 0xf2, 0x5f, 0x53, 0x0e,
|
||||||
|
0xbe, 0xd7, 0xe6, 0x1a, 0xd5, 0x75, 0x30, 0xa2, 0xaf, 0xff, 0x5e, 0x5e, 0x52, 0x94, 0x4f, 0x21,
|
||||||
|
0x29, 0x08, 0xa5, 0xbf, 0xbc, 0xff, 0xfb, 0xc1, 0xf6, 0xc9, 0x77, 0xc4, 0x88, 0x96, 0xee, 0x22,
|
||||||
|
0xbd, 0x16, 0x4f, 0xff, 0xea, 0x6c, 0x84, 0x20, 0xfc, 0xc6, 0x13, 0x44, 0x4d, 0x48, 0x56, 0x7f,
|
||||||
|
0xff, 0x5d, 0x66, 0xc1, 0x99, 0x29, 0xb7, 0xe2, 0x87, 0x73, 0x1f, 0xdc, 0x02, 0xf5, 0xff, 0xf8,
|
||||||
|
0x93, 0x6e, 0xdb, 0xb9, 0xee, 0x66, 0x4b, 0x3c, 0xfd, 0x63, 0xc6, 0x4c, 0x7b, 0xff, 0xf2, 0x3f,
|
||||||
|
0xb0, 0x4a, 0xa8, 0xc5, 0x0e, 0x87, 0x4e, 0x84, 0xf5, 0x99, 0x36, 0x80, 0xac, 0x7f, 0xfe, 0x4f,
|
||||||
|
0x7b, 0xad, 0x46, 0x83, 0x6c, 0xb9, 0xa6, 0x5d, 0x76, 0xca, 0x57, 0x08, 0xff, 0xfc, 0x76, 0xd9,
|
||||||
|
0x45, 0x85, 0x77, 0xf4, 0x9a, 0x09, 0xcb, 0xc2, 0x6e, 0x53, 0x03, 0xa7, 0x64, 0x00, 0x78, 0x92,
|
||||||
|
0xba, 0xea, 0xdd, 0xb9, 0xae, 0xec, 0xc9, 0xdb, 0x6a, 0xab, 0xff, 0xf6, 0x2c, 0x94, 0x07, 0x9e,
|
||||||
|
0x76, 0x5b, 0x4e, 0x17, 0x35, 0x0b, 0xad, 0xd6, 0x79, 0xff, 0xfb, 0x5b, 0x84, 0xd1, 0x00, 0x16,
|
||||||
|
0xc4, 0x5b, 0xa1, 0x12, 0xa1, 0xaf, 0x57, 0xd5, 0x8a, 0x3f, 0xff, 0x22, 0xe5, 0xff, 0xe7, 0x9e,
|
||||||
|
0x2a, 0x10, 0xbe, 0x98, 0xff, 0x4e, 0x32, 0x46, 0x9f, 0xff, 0x90, 0x1c, 0x19, 0x58, 0xeb, 0x75,
|
||||||
|
0xba, 0x0b, 0x50, 0xf1, 0xa6, 0xfd, 0x2c, 0xf7, 0x4a, 0xff, 0xff, 0xae, 0xb3, 0x60, 0xcc, 0x94,
|
||||||
|
0xdb, 0xf1, 0x43, 0xb9, 0x93, 0xab, 0xff, 0xd7, 0x10, 0x0f, 0x3b, 0x8a, 0xf4, 0xc9, 0x7c, 0x5d,
|
||||||
|
0xbb, 0xf2, 0xf3, 0xbf, 0xfe, 0x66, 0x6f, 0x82, 0x36, 0xed, 0xe9, 0xbf, 0x6d, 0x54, 0xc7, 0x9f,
|
||||||
|
0xff, 0x31, 0xaf, 0x10, 0x37, 0x79, 0xc3, 0xe4, 0x44, 0x04, 0xc7, 0x9b, 0xa2, 0xaf, 0xff, 0xd6,
|
||||||
|
0xbd, 0x8c, 0x63, 0x02, 0xd0, 0x1b, 0xdc, 0x6f, 0xdb, 0x96, 0x9f, 0xfe, 0xb0, 0xd5, 0x05, 0x68,
|
||||||
|
0x65, 0xb1, 0x62, 0xa5, 0xb2, 0x19, 0xb5, 0x97, 0xff, 0xf2, 0xf0, 0xd6, 0xb5, 0xa6, 0x77, 0xe3,
|
||||||
|
0x3d, 0x8d, 0x17, 0xb5, 0x4f, 0xff, 0x95, 0xc6, 0x26, 0x53, 0xc2, 0x7f, 0xfb, 0x3c, 0xb7, 0x47,
|
||||||
|
0xc6, 0xca, 0x79, 0xc8, 0x7f, 0xff, 0xea, 0x14, 0x53, 0xa7, 0x46, 0x89, 0xb3, 0x0b, 0x10, 0xb1,
|
||||||
|
0x0b, 0x10, 0xb1, 0x0c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xc1, 0x2c, 0x12, 0xbf, 0xcb,
|
||||||
|
0xff, 0xfe, 0x78, 0xff, 0xb5, 0x5e, 0xcd, 0x7d, 0xb8, 0x9e, 0x9b, 0xf6, 0x77, 0x9c, 0xb6, 0x6b,
|
||||||
|
0xb3, 0xd6, 0xbc, 0x5a, 0x3a, 0x85, 0x00, 0x5d, 0x6e, 0x8f, 0x57, 0x3b, 0xf7, 0x0d, 0x2d, 0x5f,
|
||||||
|
0x2f, 0xa4, 0xbe, 0x92, 0xfa, 0x4b, 0xe9, 0x30, 0x02, 0x40, 0x09, 0x00, 0x24, 0x00, 0x90, 0x02,
|
||||||
|
0x40, 0x09, 0x00, 0x23, 0xe9, 0x97, 0xae, 0xc0, 0x38, 0xb7, 0x4c, 0x8b, 0x9f, 0x2b, 0x82, 0xc6,
|
||||||
|
0x93, 0xf6, 0xc3, 0xdb, 0x0f, 0x6c, 0x3d, 0xb0, 0xf6, 0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xbf, 0x26,
|
||||||
|
0xfc, 0x9b, 0xf2, 0x6f, 0xc9, 0xb8, 0xd8, 0x91, 0x7f, 0xff, 0xde, 0xf2, 0x9f, 0x72, 0x5f, 0xa4,
|
||||||
|
0xdf, 0x42, 0xd1, 0x53, 0xe9, 0xe1, 0x81, 0x9b, 0x85, 0x88, 0x7b, 0xb1, 0xf1, 0xff, 0xfd, 0xed,
|
||||||
|
0xc1, 0xe8, 0x25, 0x65, 0xdd, 0x23, 0xcd, 0xe2, 0x3c, 0xbd, 0xd5, 0x70, 0xef, 0xfc, 0x2b, 0x7e,
|
||||||
|
0xd8, 0x76, 0x9a, 0x5f, 0xff, 0xef, 0x62, 0xf8, 0x1e, 0xc4, 0x28, 0xd5, 0xd5, 0x53, 0x4b, 0xb8,
|
||||||
|
0x01, 0x3b, 0x94, 0x00, 0x9d, 0xe8, 0x5e, 0x76, 0xaf, 0xff, 0xef, 0x57, 0x8e, 0x51, 0x21, 0xe7,
|
||||||
|
0xdc, 0x18, 0x7b, 0xe1, 0xc7, 0xea, 0x80, 0xfa, 0xfa, 0x1b, 0x98, 0x35, 0x31, 0x02, 0x2f, 0xff,
|
||||||
|
0xec, 0x5a, 0x9f, 0x51, 0x14, 0xaa, 0x31, 0xb1, 0x17, 0x53, 0x71, 0xf9, 0x54, 0xac, 0xfc, 0xaa,
|
||||||
|
0x7a, 0x67, 0x94, 0x7f, 0xff, 0xec, 0x4f, 0x2d, 0xf7, 0xe1, 0xd4, 0x9a, 0xcb, 0x35, 0xe5, 0xbc,
|
||||||
|
0x5a, 0xc5, 0x0a, 0xf2, 0xf4, 0xb6, 0x9e, 0x56, 0x89, 0x79, 0x8c, 0xff, 0xfe, 0xc2, 0x0e, 0xc5,
|
||||||
|
0x55, 0x55, 0x55, 0x56, 0x73, 0xcf, 0x3c, 0xf3, 0xcf, 0x44, 0x4f, 0xff, 0xc9, 0xed, 0xa4, 0x9b,
|
||||||
|
0x63, 0x01, 0x60, 0x76, 0xb4, 0x22, 0x96, 0xdd, 0xff, 0xff, 0xe8, 0x9f, 0xd0, 0x8f, 0x37, 0x39,
|
||||||
|
0x09, 0x7a, 0x97, 0xa9, 0x7a, 0x97, 0xa9, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf, 0x7b, 0xf7, 0xbf,
|
||||||
|
0x7b, 0xf7, 0xaa, 0x37, 0xc8, 0x01, 0x25, 0x4b, 0xfb, 0xde, 0xf7, 0xbe, 0x3c, 0x63, 0x18, 0xc6,
|
||||||
|
0x2e, 0xb9, 0xdf, 0x3f, 0xff, 0x37, 0x0d, 0x7b, 0x55, 0x6f, 0xef, 0x54, 0x55, 0x48, 0x47, 0xaf,
|
||||||
|
0xff, 0x93, 0x79, 0x1c, 0xfd, 0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x94, 0x8b, 0xb5, 0xff, 0xf2,
|
||||||
|
0x9c, 0x0c, 0x63, 0x11, 0xa5, 0x98, 0x4e, 0xe1, 0x5d, 0xe0, 0xaf, 0xff, 0x93, 0x79, 0x1c, 0xfd,
|
||||||
|
0xc6, 0xb6, 0x82, 0x10, 0xbe, 0xf2, 0x80, 0x2b, 0x7f, 0xfc, 0xa7, 0x03, 0x18, 0xc4, 0x69, 0x66,
|
||||||
|
0x13, 0xb8, 0x57, 0x78, 0x2b, 0xff, 0xe4, 0xde, 0x47, 0x3f, 0x71, 0xad, 0xa0, 0x84, 0x2f, 0xbc,
|
||||||
|
0xa9, 0x90, 0x7f, 0xff, 0xff, 0x61, 0x11, 0x60, 0xbe, 0x7a, 0x34, 0x05, 0xfa, 0x7c, 0x2c, 0x02,
|
||||||
|
0x93, 0x7f, 0x8f, 0x3b, 0x49, 0x78, 0xe9, 0x6f, 0xff, 0xf4, 0x70, 0xc4, 0xb3, 0x3c, 0x00, 0xcf,
|
||||||
|
0x71, 0x94, 0xc8, 0x94, 0xdc, 0x24, 0xd1, 0x48, 0xe9, 0xf2, 0x10, 0xa7, 0xa2, 0x2f, 0xff, 0xef,
|
||||||
|
0x2a, 0x8c, 0x71, 0x0f, 0x59, 0xc2, 0x0c, 0x95, 0x81, 0x8b, 0xe2, 0x2b, 0xe1, 0xee, 0x90, 0x10,
|
||||||
|
0xb3, 0x86, 0xff, 0xfe, 0xf2, 0xa8, 0xc7, 0x10, 0xf5, 0x9c, 0x20, 0xc9, 0x58, 0x18, 0xbe, 0x22,
|
||||||
|
0xbe, 0x1e, 0xe9, 0x01, 0x0b, 0x1e, 0xd5, 0x01, 0xc0, 0x08, 0x99, 0x6e, 0xc0, 0x1b, 0x0a, 0x14,
|
||||||
|
0xed, 0x5f, 0x98, 0xe1, 0xe9, 0x7f, 0xff, 0x9c, 0x7e, 0xd2, 0x28, 0xb1, 0xf8, 0xa4, 0xa1, 0x96,
|
||||||
|
0xc9, 0x93, 0x29, 0x3e, 0xe9, 0x94, 0xa0, 0x2a, 0xab, 0x01, 0x7f, 0xff, 0x9c, 0x7d, 0xda, 0x96,
|
||||||
|
0xf9, 0xd2, 0x69, 0xb0, 0xa7, 0x3b, 0x45, 0x93, 0xda, 0xa0, 0x79, 0x82, 0x63, 0xd1, 0xee, 0x72,
|
||||||
|
0x7f, 0xff, 0x70, 0x31, 0x67, 0xd0, 0x12, 0x65, 0x89, 0x85, 0x1f, 0x93, 0x7d, 0xd7, 0x33, 0x1e,
|
||||||
|
0xeb, 0x9a, 0x96, 0x50, 0x7e, 0x7f, 0xff, 0x70, 0x30, 0x02, 0x04, 0xb5, 0xf0, 0xc5, 0xf9, 0xe0,
|
||||||
|
0x80, 0x2d, 0x60, 0x15, 0xf9, 0x1c, 0x28, 0xfe, 0xa0, 0x24, 0x9b, 0x49, 0xff, 0xec, 0x64, 0xeb,
|
||||||
|
0x1e, 0x03, 0xe5, 0xf2, 0x2e, 0xab, 0xf9, 0x94, 0xff, 0xf6, 0x32, 0x75, 0x8f, 0x01, 0xf2, 0xf9,
|
||||||
|
0x17, 0x55, 0xfa, 0x3d, 0x5f, 0x6f, 0xff, 0x7a, 0xee, 0x0e, 0xbd, 0xf3, 0xa6, 0xca, 0xe6, 0x65,
|
||||||
|
0xee, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73, 0x32, 0xe6, 0x3a, 0x79, 0xc9,
|
||||||
|
0xff, 0xec, 0xa5, 0xd2, 0x94, 0x9a, 0xdc, 0x53, 0x86, 0xce, 0x4c, 0x7d, 0x4f, 0xff, 0x63, 0x27,
|
||||||
|
0x58, 0xf0, 0x1f, 0x2f, 0x91, 0x75, 0x5f, 0xa3, 0xdf, 0x42, 0xff, 0xf7, 0xca, 0x69, 0x4a, 0x4e,
|
||||||
|
0x36, 0x07, 0xe4, 0x5f, 0xa6, 0x64, 0xd7, 0xff, 0xbd, 0x77, 0x07, 0x5e, 0xf9, 0xd3, 0x65, 0x73,
|
||||||
|
0x32, 0xe6, 0x31, 0xc9, 0xa5, 0xc6, 0xbf, 0xff, 0xb9, 0x36, 0x97, 0x97, 0x1b, 0x06, 0x0c, 0x18,
|
||||||
|
0x32, 0x9b, 0x36, 0x6c, 0xd9, 0xb3, 0x66, 0x6f, 0x5f, 0xff, 0xa7, 0x4d, 0x7d, 0x81, 0x4a, 0x12,
|
||||||
|
0x07, 0xa6, 0x03, 0x36, 0x99, 0x29, 0xfb, 0x89, 0x00, 0x29, 0x36, 0xb1, 0xcb, 0x53, 0xbb, 0x3f,
|
||||||
|
0x16, 0x2b, 0x94, 0x29, 0x42, 0x94, 0x29, 0x42, 0x94, 0xce, 0x4c, 0xe4, 0xce, 0x4c, 0xe4, 0xce,
|
||||||
|
0x4c, 0xe4, 0xce, 0x43, 0x05, 0x97, 0xff, 0xff, 0x71, 0xfd, 0xb8, 0xb5, 0xb7, 0x91, 0x60, 0x43,
|
||||||
|
0xbc, 0x3b, 0xc3, 0xbc, 0x3b, 0xc3, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0, 0xbe, 0x0b, 0xe0,
|
||||||
|
0xbb, 0xdf, 0x60, 0x17, 0xff, 0xe9, 0xd3, 0x5f, 0x60, 0x52, 0x84, 0x81, 0xe9, 0x80, 0xcd, 0xa6,
|
||||||
|
0x4a, 0xbc, 0xaf, 0xff, 0xd3, 0xa6, 0xbe, 0xc0, 0xa5, 0x09, 0x03, 0xd3, 0x01, 0x9b, 0x4c, 0x95,
|
||||||
|
0x00, 0x59, 0xd7, 0xef, 0xff, 0xdb, 0x33, 0xb2, 0x06, 0x18, 0x54, 0x3c, 0x70, 0xf1, 0xf0, 0x79,
|
||||||
|
0x40, 0x76, 0xf7, 0xff, 0xed, 0x96, 0x31, 0x59, 0x07, 0xe9, 0xf6, 0x47, 0xc7, 0x39, 0xb1, 0xe3,
|
||||||
|
0x67, 0x37, 0x16, 0xff, 0xfd, 0x3a, 0xf8, 0xc4, 0xf3, 0xce, 0x3c, 0xec, 0x73, 0x29, 0xb9, 0x9a,
|
||||||
|
0x4c, 0xa5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e, 0x98, 0x0c, 0xda, 0x64, 0xa8,
|
||||||
|
0x07, 0x39, 0xcf, 0x8d, 0x7f, 0xfe, 0x9d, 0x7c, 0x62, 0x79, 0xe7, 0x9e, 0x7b, 0x07, 0x1c, 0x71,
|
||||||
|
0xc7, 0x1c, 0x5a, 0x57, 0xff, 0xc0, 0xe0, 0x1d, 0x85, 0xc5, 0x93, 0xe7, 0xb5, 0x6b, 0x38, 0x79,
|
||||||
|
0xff, 0xff, 0xf6, 0xf8, 0x6d, 0xad, 0xb2, 0x85, 0x51, 0x0c, 0xb0, 0xcb, 0x0c, 0xb0, 0xcb, 0x0d,
|
||||||
|
0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xd3, 0x6d, 0x36, 0xcb, 0x4a, 0x10, 0x05, 0xd0, 0x23,
|
||||||
|
0x08, 0x42, 0x10, 0x85, 0xa7, 0x39, 0xce, 0x73, 0x74, 0xe4, 0x2f, 0x7f, 0xfb, 0x29, 0x99, 0x54,
|
||||||
|
0x5f, 0x24, 0x27, 0x86, 0x0b, 0x58, 0x02, 0xff, 0xf6, 0x53, 0x32, 0xa8, 0xbe, 0x48, 0x4f, 0x0c,
|
||||||
|
0x16, 0xa7, 0x55, 0xbf, 0x56, 0xff, 0xf9, 0x82, 0x5a, 0xa9, 0xbf, 0xfa, 0xf8, 0x8b, 0xe9, 0x45,
|
||||||
|
0x1b, 0xff, 0xe5, 0xca, 0x81, 0xda, 0xd2, 0x08, 0x7d, 0x9a, 0x79, 0xdc, 0xd1, 0x79, 0x7f, 0xfc,
|
||||||
|
0x17, 0x7a, 0x21, 0x94, 0xd2, 0x7c, 0x99, 0xf6, 0xac, 0x65, 0xff, 0xf0, 0x38, 0x07, 0x61, 0x71,
|
||||||
|
0x64, 0xf9, 0xed, 0x5a, 0xce, 0xb5, 0x4a, 0x6d, 0xff, 0xfb, 0x65, 0x8c, 0x56, 0x41, 0xfa, 0x7d,
|
||||||
|
0x91, 0xf1, 0xce, 0x6c, 0x78, 0xe5, 0x7b, 0xff, 0xf6, 0xcb, 0x18, 0xac, 0x83, 0xf4, 0xfb, 0x23,
|
||||||
|
0xe3, 0x9c, 0xd8, 0xf1, 0xb3, 0xa9, 0xf5, 0x7f, 0xfe, 0x9d, 0x35, 0xf6, 0x05, 0x28, 0x48, 0x1e,
|
||||||
|
0x98, 0x0c, 0xda, 0x64, 0xab, 0xca, 0xff, 0xfd, 0x3a, 0x6b, 0xec, 0x0a, 0x50, 0x90, 0x3d, 0x30,
|
||||||
|
0x19, 0xb4, 0xc9, 0x4f, 0xb3, 0x9a, 0xfc, 0x01, 0x07, 0x82, 0xbd, 0x99, 0xa4, 0x39, 0xc6, 0x16,
|
||||||
|
0x38, 0xe1, 0x3e, 0x7f, 0xfe, 0xc3, 0x11, 0x1f, 0x0c, 0x30, 0x50, 0x9a, 0x8a, 0x51, 0x85, 0x2b,
|
||||||
|
0x30, 0xc4, 0xbf, 0xff, 0x56, 0x20, 0x9e, 0xf3, 0xfe, 0xee, 0xf8, 0x08, 0x93, 0x45, 0x49, 0x85,
|
||||||
|
0xce, 0x83, 0xa7, 0xff, 0xe1, 0x8b, 0x31, 0xd8, 0x61, 0x7b, 0x18, 0xf8, 0x2b, 0x60, 0x15, 0xeb,
|
||||||
|
0xd2, 0xd3, 0xff, 0xf0, 0xc2, 0x92, 0x82, 0xe0, 0xf7, 0x90, 0x77, 0x52, 0xa4, 0xdb, 0xac, 0x6b,
|
||||||
|
0xc8, 0x4a, 0xf1, 0xff, 0xea, 0x4e, 0xd2, 0xf7, 0x03, 0xe5, 0x41, 0xd4, 0x47, 0x86, 0x33, 0xff,
|
||||||
|
0xd4, 0x9d, 0xa5, 0xee, 0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe5, 0xbd, 0x2a, 0xff, 0xf8, 0x71, 0xaa,
|
||||||
|
0xe2, 0x37, 0xa6, 0x65, 0x92, 0xa9, 0xb2, 0x35, 0xff, 0xf0, 0xe3, 0x55, 0xc4, 0x6f, 0x4c, 0xcb,
|
||||||
|
0x25, 0x53, 0x56, 0xb2, 0x09, 0xed, 0xff, 0xf1, 0x07, 0x9b, 0xb9, 0x9e, 0x74, 0xf2, 0x13, 0xd8,
|
||||||
|
0xa6, 0xd7, 0xff, 0xc3, 0x8d, 0x57, 0x11, 0xbd, 0x33, 0x2c, 0x95, 0x4d, 0x5a, 0x50, 0x74, 0xff,
|
||||||
|
0xf5, 0x41, 0x72, 0x20, 0x9d, 0xa7, 0x0f, 0xfc, 0x4b, 0xab, 0x33, 0xff, 0xd4, 0x9d, 0xa5, 0xee,
|
||||||
|
0x07, 0xca, 0x83, 0xa8, 0x8e, 0xe6, 0x10, 0x50,
|
||||||
|
]
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import CoreVideo
|
||||||
|
import XCTest
|
||||||
|
import simd
|
||||||
|
|
||||||
|
@testable import PunktfunkKit
|
||||||
|
|
||||||
|
/// Mirrors pf-client-core's `csc_rows` tests (crates/pf-client-core/src/video.rs) — the Swift port
|
||||||
|
/// must stay in LOCKSTEP with the Rust implementation, so these are the same fixtures with the
|
||||||
|
/// same tolerances. A divergence here means the two sides would render the same stream
|
||||||
|
/// differently.
|
||||||
|
final class CscRowsTests: XCTestCase {
|
||||||
|
private func apply(_ u: CscUniform, _ yuv: SIMD3<Float>) -> SIMD3<Float> {
|
||||||
|
SIMD3(
|
||||||
|
simd_dot(SIMD3(u.r0.x, u.r0.y, u.r0.z), yuv) + u.r0.w,
|
||||||
|
simd_dot(SIMD3(u.r1.x, u.r1.y, u.r1.z), yuv) + u.r1.w,
|
||||||
|
simd_dot(SIMD3(u.r2.x, u.r2.y, u.r2.z), yuv) + u.r2.w)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 10-bit limited MSB-packed (P010/x444): reference white Y=940, black Y=64, neutral
|
||||||
|
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||||
|
func testBt2020TenBitLimitedWhiteBlack() {
|
||||||
|
let rows = CscRows.rows(.init(matrix: 9, fullRange: false), depth: 10, msbPacked: true)
|
||||||
|
func s(_ code: UInt32) -> Float { Float(code << 6) / 65535.0 }
|
||||||
|
let white = apply(rows, SIMD3(s(940), s(512), s(512)))
|
||||||
|
let black = apply(rows, SIMD3(s(64), s(512), s(512)))
|
||||||
|
for i in 0..<3 {
|
||||||
|
XCTAssertEqual(white[i], 1.0, accuracy: 0.002, "white \(white)")
|
||||||
|
XCTAssertEqual(black[i], 0.0, accuracy: 0.002, "black \(black)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0.
|
||||||
|
func testBt709LimitedWhiteBlack() {
|
||||||
|
let rows = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
|
||||||
|
let white = apply(rows, SIMD3(235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
|
||||||
|
let black = apply(rows, SIMD3(16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0))
|
||||||
|
for i in 0..<3 {
|
||||||
|
XCTAssertEqual(white[i], 1.0, accuracy: 0.005, "white \(white)")
|
||||||
|
XCTAssertEqual(black[i], 0.0, accuracy: 0.005, "black \(black)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-range identity points + the 601-vs-709 red excursion (guards the matrix-code
|
||||||
|
/// dispatch — the two matrices MUST differ measurably, that difference is the whole bug
|
||||||
|
/// class this port fixes).
|
||||||
|
func testFullRangeAndRedExcursion() {
|
||||||
|
let rows601 = CscRows.rows(.init(matrix: 5, fullRange: true), depth: 8, msbPacked: false)
|
||||||
|
let white = apply(rows601, SIMD3(1.0, 0.5, 0.5))
|
||||||
|
for i in 0..<3 {
|
||||||
|
XCTAssertEqual(white[i], 1.0, accuracy: 1e-5, "\(white)")
|
||||||
|
}
|
||||||
|
let red601 = apply(rows601, SIMD3(0.0, 0.5, 1.0))
|
||||||
|
XCTAssertEqual(red601[0], 2.0 * (1.0 - 0.299) * 0.5, accuracy: 1e-4, "\(red601)")
|
||||||
|
let rows709 = CscRows.rows(.init(matrix: 1, fullRange: true), depth: 8, msbPacked: false)
|
||||||
|
let red709 = apply(rows709, SIMD3(0.0, 0.5, 1.0))
|
||||||
|
XCTAssertEqual(red709[0], 2.0 * (1.0 - 0.2126) * 0.5, accuracy: 1e-4, "\(red709)")
|
||||||
|
XCTAssertGreaterThan(abs(red601[0] - red709[0]), 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unspecified (2) and unknown matrix codes fall back to BT.709 — the same default as the
|
||||||
|
/// Rust side and every punktfunk host's implicit SDR baseline.
|
||||||
|
func testUnspecifiedFallsBackTo709() {
|
||||||
|
let unspec = CscRows.rows(.init(matrix: 2, fullRange: false), depth: 8, msbPacked: false)
|
||||||
|
let bt709 = CscRows.rows(.init(matrix: 1, fullRange: false), depth: 8, msbPacked: false)
|
||||||
|
XCTAssertEqual(unspec, bt709)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `signal(of:)` reads the matrix off the buffer's attachment (what VideoToolbox propagates
|
||||||
|
/// from the VUI) and the range off the pixel format — a 601-tagged buffer must come back as
|
||||||
|
/// matrix 5, an untagged one as unspecified (2), and a full-range sibling as fullRange.
|
||||||
|
func testSignalReadsAttachmentAndRange() throws {
|
||||||
|
func makeBuffer(_ format: OSType) throws -> CVPixelBuffer {
|
||||||
|
var pb: CVPixelBuffer?
|
||||||
|
let status = CVPixelBufferCreate(kCFAllocatorDefault, 64, 64, format, nil, &pb)
|
||||||
|
guard status == kCVReturnSuccess, let pb else {
|
||||||
|
throw XCTSkip("could not allocate a \(format) pixel buffer")
|
||||||
|
}
|
||||||
|
return pb
|
||||||
|
}
|
||||||
|
|
||||||
|
let tagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
|
||||||
|
CVBufferSetAttachment(
|
||||||
|
tagged, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_601_4,
|
||||||
|
.shouldPropagate)
|
||||||
|
XCTAssertEqual(CscRows.signal(of: tagged), CscRows.Signal(matrix: 5, fullRange: false))
|
||||||
|
|
||||||
|
let untagged = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange)
|
||||||
|
XCTAssertEqual(CscRows.signal(of: untagged), CscRows.Signal(matrix: 2, fullRange: false))
|
||||||
|
|
||||||
|
let full = try makeBuffer(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange)
|
||||||
|
CVBufferSetAttachment(
|
||||||
|
full, kCVImageBufferYCbCrMatrixKey, kCVImageBufferYCbCrMatrix_ITU_R_2020,
|
||||||
|
.shouldPropagate)
|
||||||
|
XCTAssertEqual(CscRows.signal(of: full), CscRows.Signal(matrix: 9, fullRange: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -111,6 +111,11 @@ struct Args {
|
|||||||
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
/// `--discover [SECS]` — browse the LAN for native (`_punktfunk._udp`) hosts for `SECS`
|
||||||
/// seconds (default 4), print what's found, and exit. No connection is made.
|
/// seconds (default 4), print what's found, and exit. No connection is made.
|
||||||
discover: Option<u64>,
|
discover: Option<u64>,
|
||||||
|
/// `--clock-resync` — after the connect-time skew handshake, immediately run a SECOND
|
||||||
|
/// handshake on the same control stream and assert both estimates are sane and consistent:
|
||||||
|
/// the headless validator for the host answering `ClockProbe` at any time (what the native
|
||||||
|
/// clients' mid-stream re-sync relies on). Aborts the session when the re-probe fails.
|
||||||
|
clock_resync: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_mode(m: &str) -> Option<Mode> {
|
fn parse_mode(m: &str) -> Option<Mode> {
|
||||||
@@ -274,6 +279,7 @@ fn parse_args() -> Args {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|a| a == "--discover")
|
.any(|a| a == "--discover")
|
||||||
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
.then(|| get("--discover").and_then(|s| s.parse().ok()).unwrap_or(4)),
|
||||||
|
clock_resync: argv.iter().any(|a| a == "--clock-resync"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,7 +529,8 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
|
// Wall-clock skew handshake on the still-private control stream (before --remode/--speed-test
|
||||||
// take it): align our clock to the host's so the per-frame capture→received latency is valid
|
// take it): align our clock to the host's so the per-frame capture→received latency is valid
|
||||||
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
|
// across machines. `None` ⇒ an old host that doesn't answer — fall back to a shared clock (0).
|
||||||
let clock_offset_ns = match punktfunk_core::quic::clock_sync(&mut send, &mut recv).await {
|
let first_skew = punktfunk_core::quic::clock_sync(&mut send, &mut recv).await;
|
||||||
|
let clock_offset_ns = match &first_skew {
|
||||||
Some(skew) => {
|
Some(skew) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
offset_ns = skew.offset_ns,
|
offset_ns = skew.offset_ns,
|
||||||
@@ -536,6 +543,42 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `--clock-resync`: prove the host answers `ClockProbe` mid-session, not just at connect —
|
||||||
|
// the contract the native clients' mid-stream re-sync rests on. Run a full second handshake
|
||||||
|
// and require a sane, consistent estimate: both batches measure the same physical skew, so
|
||||||
|
// they must agree to within RTT-scale error (the handshake's own uncertainty is ≈ RTT/2).
|
||||||
|
if args.clock_resync {
|
||||||
|
let first = first_skew.as_ref().ok_or_else(|| {
|
||||||
|
anyhow!("clock-resync: host never answered the connect-time handshake")
|
||||||
|
})?;
|
||||||
|
let second = punktfunk_core::quic::clock_sync(&mut send, &mut recv)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow!("clock-resync: host did not answer the re-probe"))?;
|
||||||
|
let disagree_ns = (second.offset_ns - first.offset_ns).unsigned_abs();
|
||||||
|
let bound_ns = (first.rtt_ns + second.rtt_ns).max(2_000_000);
|
||||||
|
tracing::info!(
|
||||||
|
first_offset_ns = first.offset_ns,
|
||||||
|
second_offset_ns = second.offset_ns,
|
||||||
|
disagree_us = disagree_ns / 1000,
|
||||||
|
bound_us = bound_ns / 1000,
|
||||||
|
second_rtt_us = second.rtt_ns / 1000,
|
||||||
|
rounds = second.rounds,
|
||||||
|
"clock re-probe answered"
|
||||||
|
);
|
||||||
|
if second.rounds < 8 || disagree_ns > bound_ns {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"clock-resync: re-probe unsound (rounds {}, disagreement {} µs > bound {} µs)",
|
||||||
|
second.rounds,
|
||||||
|
disagree_ns / 1000,
|
||||||
|
bound_ns / 1000
|
||||||
|
));
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
"clock-resync OK: offsets {} / {} ns",
|
||||||
|
first.offset_ns, second.offset_ns
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
// Packet-level receive counters mirrored from `session.stats()` by the data-plane loop. The
|
||||||
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
// speed test reads their delta over the burst window so throughput/loss reflect every delivered
|
||||||
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
// wire packet (graceful past the FEC budget), not just fully-reassembled probe AUs.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ impl PartialEq for StreamProps {
|
|||||||
thread_local! {
|
thread_local! {
|
||||||
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
/// Frames + host clock offset, stashed by the mount effect for `on_mounted` (which fires
|
||||||
/// later, once the native panel exists).
|
/// later, once the native panel exists).
|
||||||
static PENDING: RefCell<Option<(crate::session::FrameRx, i64)>> = const { RefCell::new(None) };
|
static PENDING: RefCell<Option<(crate::session::FrameRx, std::sync::Arc<std::sync::atomic::AtomicI64>)>> = const { RefCell::new(None) };
|
||||||
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
/// The live render thread; stopped + joined by the unmount cleanup (before panel teardown).
|
||||||
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
static RENDER: RefCell<Option<RenderThread>> = const { RefCell::new(None) };
|
||||||
}
|
}
|
||||||
@@ -88,7 +88,7 @@ pub(crate) fn stream_page(props: &StreamProps, cx: &mut RenderCx) -> Element {
|
|||||||
move || {
|
move || {
|
||||||
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
if let Some((connector, frames, stop)) = shared.handoff.lock().unwrap().take() {
|
||||||
let mode = connector.mode();
|
let mode = connector.mode();
|
||||||
let clock_offset = connector.clock_offset_ns;
|
let clock_offset = connector.clock_offset_shared();
|
||||||
connector_ref.set(Some(connector.clone()));
|
connector_ref.set(Some(connector.clone()));
|
||||||
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
PENDING.with(|c| *c.borrow_mut() = Some((frames, clock_offset)));
|
||||||
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
crate::input::install(connector, mode, inhibit, show_stats, stop);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
//! the dedicated render thread ([`crate::render`]) — presenting never touches (or is stalled by)
|
||||||
//! the XAML thread.
|
//! the XAML thread.
|
||||||
//!
|
//!
|
||||||
//! Two frame sources, one pair of YUV shaders (identical colour math for both):
|
//! Two frame sources, ONE Y′CbCr→RGB shader whose conversion rows arrive per frame in a constant
|
||||||
|
//! buffer (`pf_client_core::video::csc_rows` from the frame's CICP signaling — identical colour
|
||||||
|
//! math for both sources, and the stream's signaled matrix/range is honored, not assumed):
|
||||||
//!
|
//!
|
||||||
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
//! * **GPU (D3D11VA)** — [`crate::video::GpuFrame`] is a slice of the decoder-only NV12/P010
|
||||||
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
//! texture array. One `CopySubresourceRegion` with a display-size box moves the slice — **both
|
||||||
@@ -46,10 +48,14 @@ use windows::Win32::Graphics::Dxgi::Common::*;
|
|||||||
use windows::Win32::Graphics::Dxgi::*;
|
use windows::Win32::Graphics::Dxgi::*;
|
||||||
use windows::Win32::System::Threading::WaitForSingleObject;
|
use windows::Win32::System::Threading::WaitForSingleObject;
|
||||||
|
|
||||||
// One vertex shader (fullscreen triangle) + two pixel shaders, selected per frame colour space.
|
// One vertex shader (fullscreen triangle) + ONE pixel shader for every colour combination:
|
||||||
// tex0 is the luma plane, tex1 the chroma plane. The YUV→RGB matrices fold the limited→full range
|
// tex0 is the luma plane, tex1 the chroma plane, and the Y′CbCr→RGB conversion arrives as three
|
||||||
// scale into the coefficients; for P010 the R16 sample is rescaled (×65535/65472) to undo the
|
// constant-buffer rows precomputed on the CPU per frame (`pf_client_core::video::csc_rows` —
|
||||||
// 10-bits-in-the-high-bits packing, then converted with BT.2020 NCL, PQ preserved.
|
// bit-depth exact, range expansion + the P010 ×65535/65472 high-bit repack folded in). One shader
|
||||||
|
// honors whatever the stream signals (BT.601/709/2020, full/limited, 8/10-bit) instead of the old
|
||||||
|
// two hardcoded matrices — a BT.601-signaled stream (a Linux host's RGB-input NVENC) used to
|
||||||
|
// render with BT.709 coefficients, a constant hue error. A PQ stream's rows yield PQ-encoded
|
||||||
|
// R′G′B′ passed through as-is to the HDR10 swapchain, exactly as before.
|
||||||
const SHADER_HLSL: &str = r#"
|
const SHADER_HLSL: &str = r#"
|
||||||
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
struct VSOut { float4 pos : SV_Position; float2 uv : TEXCOORD0; };
|
||||||
VSOut vs_main(uint vid : SV_VertexID) {
|
VSOut vs_main(uint vid : SV_VertexID) {
|
||||||
@@ -62,47 +68,47 @@ VSOut vs_main(uint vid : SV_VertexID) {
|
|||||||
Texture2D tex0 : register(t0);
|
Texture2D tex0 : register(t0);
|
||||||
Texture2D tex1 : register(t1);
|
Texture2D tex1 : register(t1);
|
||||||
SamplerState smp : register(s0);
|
SamplerState smp : register(s0);
|
||||||
|
cbuffer Csc : register(b0) {
|
||||||
|
float4 r0; // rgb[i] = dot(ri.xyz, yuv) + ri.w
|
||||||
|
float4 r1;
|
||||||
|
float4 r2;
|
||||||
|
};
|
||||||
|
|
||||||
float4 ps_nv12(VSOut i) : SV_Target {
|
float4 ps_yuv(VSOut i) : SV_Target {
|
||||||
float y = tex0.Sample(smp, i.uv).r;
|
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||||
float2 uv = tex1.Sample(smp, i.uv).rg;
|
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||||
float yy = (y - 0.0627451) * 1.164384; // (Y-16/255)*255/219
|
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||||
float u = uv.x - 0.5;
|
// texels to re-align (the same correction the Apple client applies). Self-disables when the
|
||||||
float v = uv.y - 0.5; // BT.709 limited, chroma scale folded
|
// plane widths match (a full-size 4:4:4 chroma plane has no subsampling to correct).
|
||||||
float r = yy + 1.792741 * v;
|
float lw, lh, cw, ch;
|
||||||
float g = yy - 0.213249 * u - 0.532909 * v;
|
tex0.GetDimensions(lw, lh);
|
||||||
float b = yy + 2.112402 * u;
|
tex1.GetDimensions(cw, ch);
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
float2 cuv = i.uv;
|
||||||
}
|
if (cw < lw) { cuv.x += 0.25 / cw; }
|
||||||
|
float3 yuv = float3(tex0.Sample(smp, i.uv).r, tex1.Sample(smp, cuv).rg);
|
||||||
float4 ps_p010(VSOut i) : SV_Target {
|
float3 rgb = float3(dot(r0.xyz, yuv) + r0.w,
|
||||||
const float S = 65535.0 / 65472.0; // undo P010 high-bit packing → exact 10-bit / 1023
|
dot(r1.xyz, yuv) + r1.w,
|
||||||
float y = tex0.Sample(smp, i.uv).r * S;
|
dot(r2.xyz, yuv) + r2.w);
|
||||||
float2 uv = tex1.Sample(smp, i.uv).rg * S;
|
return float4(saturate(rgb), 1.0);
|
||||||
float yy = (y - 0.0625611) * 1.167808; // (Y-64/1023)*1023/876
|
|
||||||
float u = uv.x - 0.5;
|
|
||||||
float v = uv.y - 0.5; // BT.2020 NCL limited, chroma scale folded; PQ kept
|
|
||||||
float r = yy + 1.683611 * v;
|
|
||||||
float g = yy - 0.187877 * u - 0.652337 * v;
|
|
||||||
float b = yy + 2.148072 * u;
|
|
||||||
return float4(saturate(float3(r, g, b)), 1.0);
|
|
||||||
}
|
}
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
/// The currently bound frame: per-plane SRVs (over the GPU sample texture or the CPU plane
|
||||||
/// textures) + the colour space that picks the shader. Redraws (resize, letterbox) re-present it.
|
/// textures). Redraws (resize, letterbox) re-present it — the CSC constant buffer still holds
|
||||||
|
/// this frame's rows, and the swapchain mode was latched by `set_hdr` when the frame arrived.
|
||||||
struct Bound {
|
struct Bound {
|
||||||
y: ID3D11ShaderResourceView,
|
y: ID3D11ShaderResourceView,
|
||||||
c: ID3D11ShaderResourceView,
|
c: ID3D11ShaderResourceView,
|
||||||
hdr: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Presenter {
|
pub struct Presenter {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
vs: ID3D11VertexShader,
|
vs: ID3D11VertexShader,
|
||||||
ps_nv12: ID3D11PixelShader,
|
ps_yuv: ID3D11PixelShader,
|
||||||
ps_p010: ID3D11PixelShader,
|
/// Dynamic constant buffer holding the bound frame's three CSC rows (`csc_rows`), rewritten
|
||||||
|
/// on every bind (colour signaling can flip in-band, e.g. the host's SDR→HDR re-init).
|
||||||
|
csc_buf: ID3D11Buffer,
|
||||||
sampler: ID3D11SamplerState,
|
sampler: ID3D11SamplerState,
|
||||||
swap: IDXGISwapChain1,
|
swap: IDXGISwapChain1,
|
||||||
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
/// Creation flags — MUST be re-passed to every `ResizeBuffers` or it fails.
|
||||||
@@ -157,7 +163,22 @@ impl Presenter {
|
|||||||
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
let shared = crate::gpu::shared().ok_or_else(|| anyhow!("no shared D3D11 device"))?;
|
||||||
let device = shared.device.clone();
|
let device = shared.device.clone();
|
||||||
let context = shared.context.clone();
|
let context = shared.context.clone();
|
||||||
let (vs, ps_nv12, ps_p010, sampler) = build_pipeline(&device)?;
|
let (vs, ps_yuv, sampler) = build_pipeline(&device)?;
|
||||||
|
// The per-frame CSC rows (three float4s). Dynamic: rewritten with Map-discard on bind.
|
||||||
|
let csc_desc = D3D11_BUFFER_DESC {
|
||||||
|
ByteWidth: 48,
|
||||||
|
Usage: D3D11_USAGE_DYNAMIC,
|
||||||
|
BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32,
|
||||||
|
CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let csc_buf = unsafe {
|
||||||
|
let mut b = None;
|
||||||
|
device
|
||||||
|
.CreateBuffer(&csc_desc, None, Some(&mut b))
|
||||||
|
.context("CreateBuffer (CSC rows)")?;
|
||||||
|
b.ok_or_else(|| anyhow!("null CSC constant buffer"))?
|
||||||
|
};
|
||||||
let (swap, swap_flags) =
|
let (swap, swap_flags) =
|
||||||
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
create_composition_swapchain(&device, width.max(1), height.max(1))?;
|
||||||
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
// ≤1 queued present: the render thread blocks on the waitable, so a frame is only drawn
|
||||||
@@ -175,8 +196,8 @@ impl Presenter {
|
|||||||
device,
|
device,
|
||||||
context,
|
context,
|
||||||
vs,
|
vs,
|
||||||
ps_nv12,
|
ps_yuv,
|
||||||
ps_p010,
|
csc_buf,
|
||||||
sampler,
|
sampler,
|
||||||
swap,
|
swap,
|
||||||
swap_flags,
|
swap_flags,
|
||||||
@@ -327,12 +348,10 @@ impl Presenter {
|
|||||||
let (fy, fc) = plane_formats(g.ten_bit);
|
let (fy, fc) = plane_formats(g.ten_bit);
|
||||||
let y = self.plane_srv(&dst, fy)?;
|
let y = self.plane_srv(&dst, fy)?;
|
||||||
let c = self.plane_srv(&dst, fc)?;
|
let c = self.plane_srv(&dst, fc)?;
|
||||||
if g.ten_bit != g.hdr {
|
self.write_csc_rows(g.color, g.ten_bit)?;
|
||||||
warn_bitdepth_mismatch_once(g.ten_bit, g.hdr);
|
|
||||||
}
|
|
||||||
self.src_w = g.width;
|
self.src_w = g.width;
|
||||||
self.src_h = g.height;
|
self.src_h = g.height;
|
||||||
self.bound = Some(Bound { y, c, hdr: g.hdr });
|
self.bound = Some(Bound { y, c });
|
||||||
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
// Hold the frame until the next bind: its decode surface stays out of the reuse pool
|
||||||
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
// until this copy is queued ahead of any later decoder write (previous frame drops here).
|
||||||
self.gpu_frame = Some(g);
|
self.gpu_frame = Some(g);
|
||||||
@@ -428,12 +447,13 @@ impl Presenter {
|
|||||||
w.div_ceil(2) as usize * 2 * bytes,
|
w.div_ceil(2) as usize * 2 * bytes,
|
||||||
h.div_ceil(2) as usize,
|
h.div_ceil(2) as usize,
|
||||||
)?;
|
)?;
|
||||||
|
let (y_srv, uv_srv) = (y_srv.clone(), uv_srv.clone());
|
||||||
|
self.write_csc_rows(frame.color, frame.ten_bit)?;
|
||||||
self.src_w = w;
|
self.src_w = w;
|
||||||
self.src_h = h;
|
self.src_h = h;
|
||||||
self.bound = Some(Bound {
|
self.bound = Some(Bound {
|
||||||
y: y_srv.clone(),
|
y: y_srv,
|
||||||
c: uv_srv.clone(),
|
c: uv_srv,
|
||||||
hdr: frame.hdr,
|
|
||||||
});
|
});
|
||||||
self.gpu_frame = None; // drop any held GPU frame
|
self.gpu_frame = None; // drop any held GPU frame
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -464,6 +484,32 @@ impl Presenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recompute the bound frame's Y′CbCr→RGB rows from its CICP signaling and Map-discard them
|
||||||
|
/// into the CSC constant buffer. `ten_bit` selects the 10-bit code points AND the P010
|
||||||
|
/// high-bit repack (the plane SRVs are R16/R16G16 UNORM for 10-bit).
|
||||||
|
fn write_csc_rows(&self, color: pf_client_core::video::ColorDesc, ten_bit: bool) -> Result<()> {
|
||||||
|
let rows = pf_client_core::video::csc_rows(color, if ten_bit { 10 } else { 8 }, ten_bit);
|
||||||
|
unsafe {
|
||||||
|
let mut mapped = D3D11_MAPPED_SUBRESOURCE::default();
|
||||||
|
self.context
|
||||||
|
.Map(
|
||||||
|
&self.csc_buf,
|
||||||
|
0,
|
||||||
|
D3D11_MAP_WRITE_DISCARD,
|
||||||
|
0,
|
||||||
|
Some(&mut mapped),
|
||||||
|
)
|
||||||
|
.context("Map CSC constant buffer")?;
|
||||||
|
std::ptr::copy_nonoverlapping(
|
||||||
|
rows.as_ptr() as *const u8,
|
||||||
|
mapped.pData as *mut u8,
|
||||||
|
48, // [[f32; 4]; 3]
|
||||||
|
);
|
||||||
|
self.context.Unmap(&self.csc_buf, 0);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
/// Map-discard `tex` and copy `rows` rows of `row_bytes` from `src` (stride `src_pitch`).
|
||||||
fn map_rows(
|
fn map_rows(
|
||||||
&self,
|
&self,
|
||||||
@@ -525,14 +571,8 @@ impl Presenter {
|
|||||||
c.IASetInputLayout(None);
|
c.IASetInputLayout(None);
|
||||||
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
c.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
|
||||||
c.VSSetShader(&self.vs, None);
|
c.VSSetShader(&self.vs, None);
|
||||||
c.PSSetShader(
|
c.PSSetShader(&self.ps_yuv, None);
|
||||||
if bound.hdr {
|
c.PSSetConstantBuffers(0, Some(&[Some(self.csc_buf.clone())]));
|
||||||
&self.ps_p010
|
|
||||||
} else {
|
|
||||||
&self.ps_nv12
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
c.PSSetShaderResources(0, Some(&[Some(bound.y.clone()), Some(bound.c.clone())]));
|
||||||
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
c.PSSetSamplers(0, Some(&[Some(self.sampler.clone())]));
|
||||||
c.Draw(3, 0);
|
c.Draw(3, 0);
|
||||||
@@ -645,20 +685,6 @@ fn plane_formats(ten_bit: bool) -> (DXGI_FORMAT, DXGI_FORMAT) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The host couples 10-bit ⟺ HDR today; a mismatch means the shader's transfer/matrix assumption
|
|
||||||
/// is off for this stream (rendered anyway — approximate colour beats no picture).
|
|
||||||
fn warn_bitdepth_mismatch_once(ten_bit: bool, hdr: bool) {
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
static ONCE: AtomicBool = AtomicBool::new(true);
|
|
||||||
if ONCE.swap(false, Ordering::Relaxed) {
|
|
||||||
tracing::warn!(
|
|
||||||
ten_bit,
|
|
||||||
hdr,
|
|
||||||
"bit depth / HDR mismatch — colour may be approximate"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
/// A composition flip-model swapchain (no HWND) for binding to a XAML `SwapChainPanel`, with the
|
||||||
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
/// frame-latency waitable when the driver allows it. Returns the swapchain + the flags it was
|
||||||
/// created with (every `ResizeBuffers` must re-pass them).
|
/// created with (every `ResizeBuffers` must re-pass them).
|
||||||
@@ -708,28 +734,18 @@ fn create_composition_swapchain(
|
|||||||
|
|
||||||
fn build_pipeline(
|
fn build_pipeline(
|
||||||
device: &ID3D11Device,
|
device: &ID3D11Device,
|
||||||
) -> Result<(
|
) -> Result<(ID3D11VertexShader, ID3D11PixelShader, ID3D11SamplerState)> {
|
||||||
ID3D11VertexShader,
|
|
||||||
ID3D11PixelShader,
|
|
||||||
ID3D11PixelShader,
|
|
||||||
ID3D11SamplerState,
|
|
||||||
)> {
|
|
||||||
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
let vs_blob = compile(SHADER_HLSL, "vs_main", "vs_5_0")?;
|
||||||
let nv12_blob = compile(SHADER_HLSL, "ps_nv12", "ps_5_0")?;
|
let yuv_blob = compile(SHADER_HLSL, "ps_yuv", "ps_5_0")?;
|
||||||
let p010_blob = compile(SHADER_HLSL, "ps_p010", "ps_5_0")?;
|
|
||||||
unsafe {
|
unsafe {
|
||||||
let mut vs = None;
|
let mut vs = None;
|
||||||
device
|
device
|
||||||
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
.CreateVertexShader(blob_bytes(&vs_blob), None, Some(&mut vs))
|
||||||
.context("CreateVertexShader")?;
|
.context("CreateVertexShader")?;
|
||||||
let mut ps_nv12 = None;
|
let mut ps_yuv = None;
|
||||||
device
|
device
|
||||||
.CreatePixelShader(blob_bytes(&nv12_blob), None, Some(&mut ps_nv12))
|
.CreatePixelShader(blob_bytes(&yuv_blob), None, Some(&mut ps_yuv))
|
||||||
.context("CreatePixelShader (nv12)")?;
|
.context("CreatePixelShader (yuv)")?;
|
||||||
let mut ps_p010 = None;
|
|
||||||
device
|
|
||||||
.CreatePixelShader(blob_bytes(&p010_blob), None, Some(&mut ps_p010))
|
|
||||||
.context("CreatePixelShader (p010)")?;
|
|
||||||
let sdesc = D3D11_SAMPLER_DESC {
|
let sdesc = D3D11_SAMPLER_DESC {
|
||||||
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR,
|
||||||
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
AddressU: D3D11_TEXTURE_ADDRESS_CLAMP,
|
||||||
@@ -742,12 +758,7 @@ fn build_pipeline(
|
|||||||
device
|
device
|
||||||
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
.CreateSamplerState(&sdesc, Some(&mut sampler))
|
||||||
.context("CreateSamplerState")?;
|
.context("CreateSamplerState")?;
|
||||||
Ok((
|
Ok((vs.unwrap(), ps_yuv.unwrap(), sampler.unwrap()))
|
||||||
vs.unwrap(),
|
|
||||||
ps_nv12.unwrap(),
|
|
||||||
ps_p010.unwrap(),
|
|
||||||
sampler.unwrap(),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
use crate::present::Presenter;
|
use crate::present::Presenter;
|
||||||
use crate::session::{FrameRx, FrameTimes};
|
use crate::session::{FrameRx, FrameTimes};
|
||||||
use crossbeam_channel::RecvTimeoutError;
|
use crossbeam_channel::RecvTimeoutError;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -122,12 +122,13 @@ unsafe impl Send for SendPresenter {}
|
|||||||
|
|
||||||
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
/// Spawn the render thread. `frames` carries `(frame, FrameTimes)`; `clock_offset_ns` maps our
|
||||||
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
/// wall clock onto the host's so the end-to-end (capture→on-glass) number is cross-machine valid
|
||||||
/// (same math as the pump's host+network stage).
|
/// (same math as the pump's host+network stage). A live handle (loaded per present) so
|
||||||
|
/// mid-stream clock re-syncs keep the number honest after an NTP step / drift.
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
presenter: Presenter,
|
presenter: Presenter,
|
||||||
frames: FrameRx,
|
frames: FrameRx,
|
||||||
shared: Arc<RenderShared>,
|
shared: Arc<RenderShared>,
|
||||||
clock_offset_ns: i64,
|
clock_offset_ns: Arc<AtomicI64>,
|
||||||
) -> RenderThread {
|
) -> RenderThread {
|
||||||
let boxed = SendPresenter(presenter);
|
let boxed = SendPresenter(presenter);
|
||||||
let shared_w = shared.clone();
|
let shared_w = shared.clone();
|
||||||
@@ -162,7 +163,12 @@ fn poll_window_dpi() -> Option<u32> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clock_offset_ns: i64) {
|
fn run(
|
||||||
|
presenter: SendPresenter,
|
||||||
|
frames: FrameRx,
|
||||||
|
shared: Arc<RenderShared>,
|
||||||
|
clock_offset_ns: Arc<AtomicI64>,
|
||||||
|
) {
|
||||||
let mut p = presenter.0;
|
let mut p = presenter.0;
|
||||||
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
let mut applied = (0u32, 0u32, 0u32); // last (w, h, dpi) handed to the presenter
|
||||||
let mut presented = 0u32;
|
let mut presented = 0u32;
|
||||||
@@ -232,8 +238,9 @@ fn run(presenter: SendPresenter, frames: FrameRx, shared: Arc<RenderShared>, clo
|
|||||||
let displayed_ns = now_ns();
|
let displayed_ns = now_ns();
|
||||||
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
// End-to-end = capture → displayed, host-clock corrected, measured directly
|
||||||
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
// (never the sum of stage percentiles). Clamped (0, 10 s).
|
||||||
let e2e =
|
let e2e = (displayed_ns as i128 + clock_offset_ns.load(Ordering::Relaxed) as i128
|
||||||
(displayed_ns as i128 + clock_offset_ns as i128 - t.pts_ns as i128).max(0) as u64;
|
- t.pts_ns as i128)
|
||||||
|
.max(0) as u64;
|
||||||
if e2e > 0 && e2e < 10_000_000_000 {
|
if e2e > 0 && e2e < 10_000_000_000 {
|
||||||
e2e_us.push(e2e / 1000);
|
e2e_us.push(e2e / 1000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ fn pump(
|
|||||||
// "PPS id out of range" (a black screen) until one arrives.
|
// "PPS id out of range" (a black screen) until one arrives.
|
||||||
let _ = connector.request_keyframe();
|
let _ = connector.request_keyframe();
|
||||||
|
|
||||||
let clock_offset = connector.clock_offset_ns;
|
// Live host↔client clock offset: loaded per use (Relaxed) so mid-stream re-syncs (an NTP
|
||||||
|
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||||
|
let clock_offset_live = connector.clock_offset_shared();
|
||||||
let mut total_frames = 0u64;
|
let mut total_frames = 0u64;
|
||||||
let session_start = Instant::now();
|
let session_start = Instant::now();
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
@@ -363,6 +365,7 @@ fn pump(
|
|||||||
frames_n += 1;
|
frames_n += 1;
|
||||||
bytes_n += frame.data.len() as u64;
|
bytes_n += frame.data.len() as u64;
|
||||||
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
// `host+network` stage: capture → received, host-clock corrected. Clamped (0, 10 s).
|
||||||
|
let clock_offset = clock_offset_live.load(Ordering::Relaxed);
|
||||||
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
let hostnet = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if hostnet > 0 && hostnet < 10_000_000_000 {
|
if hostnet > 0 && hostnet < 10_000_000_000 {
|
||||||
@@ -500,7 +503,7 @@ fn pump(
|
|||||||
host_ms: host_p50 as f32 / 1000.0,
|
host_ms: host_p50 as f32 / 1000.0,
|
||||||
net_ms: net_p50 as f32 / 1000.0,
|
net_ms: net_p50 as f32 / 1000.0,
|
||||||
split,
|
split,
|
||||||
same_host: clock_offset == 0,
|
same_host: clock_offset_live.load(Ordering::Relaxed) == 0,
|
||||||
hardware,
|
hardware,
|
||||||
hdr,
|
hdr,
|
||||||
codec: connector.codec,
|
codec: connector.codec,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ use ffmpeg::format::Pixel;
|
|||||||
use ffmpeg::software::scaling;
|
use ffmpeg::software::scaling;
|
||||||
use ffmpeg::util::frame::Video as AvFrame;
|
use ffmpeg::util::frame::Video as AvFrame;
|
||||||
use ffmpeg_next as ffmpeg;
|
use ffmpeg_next as ffmpeg;
|
||||||
|
use pf_client_core::video::ColorDesc;
|
||||||
use std::ffi::c_void;
|
use std::ffi::c_void;
|
||||||
use std::ptr;
|
use std::ptr;
|
||||||
use windows::core::{Interface, GUID};
|
use windows::core::{Interface, GUID};
|
||||||
@@ -95,8 +96,12 @@ pub struct CpuFrame {
|
|||||||
pub uv_stride: usize,
|
pub uv_stride: usize,
|
||||||
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
/// P010 sample layout (10 bits in the high bits of 16) vs NV12. Selects texture/SRV formats.
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects shader + swapchain colour space.
|
/// BT.2020 PQ HDR10 vs ordinary BT.709 SDR. Selects the swapchain colour space.
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
|
/// The frame's CICP signaling (HEVC VUI → `AVFrame`), read per-frame — the presenter derives
|
||||||
|
/// its Y′CbCr→RGB constant buffer from it (`csc_rows`), so a BT.601-signaled stream (a Linux
|
||||||
|
/// host's RGB-input NVENC) no longer renders with BT.709 coefficients.
|
||||||
|
pub color: ColorDesc,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
/// A decoded frame still on the GPU: a D3D11 texture **array** plus the slice index the decoder
|
||||||
@@ -112,9 +117,11 @@ pub struct GpuFrame {
|
|||||||
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
/// `sw_format`. The presenter keys its copy-texture/SRV formats off this: they must match the
|
||||||
/// source array exactly for `CopySubresourceRegion`.
|
/// source array exactly for `CopySubresourceRegion`.
|
||||||
pub ten_bit: bool,
|
pub ten_bit: bool,
|
||||||
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects shader + swapchain
|
/// BT.2020 PQ HDR10 (ST.2084 transfer) vs ordinary BT.709 SDR. Selects the swapchain colour
|
||||||
/// colour space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
/// space only (the host couples 10-bit ⟺ HDR today, but formats key off `ten_bit`).
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
|
/// Per-frame CICP signaling — see [`CpuFrame::color`].
|
||||||
|
pub color: ColorDesc,
|
||||||
guard: D3d11FrameGuard,
|
guard: D3d11FrameGuard,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,9 +336,10 @@ impl SoftwareDecoder {
|
|||||||
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
/// matrix/range/transfer handling all lives in the presenter's shaders, shared with the
|
||||||
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
/// D3D11VA path, so software frames are bit-comparable with hardware ones.
|
||||||
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
fn convert(&mut self, frame: &AvFrame) -> Result<CpuFrame> {
|
||||||
use ffmpeg::color::TransferCharacteristic;
|
|
||||||
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
let (fmt, w, h) = (frame.format(), frame.width(), frame.height());
|
||||||
let hdr = frame.color_transfer_characteristic() == TransferCharacteristic::SMPTE2084;
|
// SAFETY: `frame` wraps a live decoded AVFrame for the duration of this call.
|
||||||
|
let color = unsafe { ColorDesc::from_raw(frame.as_ptr()) };
|
||||||
|
let hdr = color.is_pq();
|
||||||
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
// Source bit depth from the pix-fmt descriptor (stable FFmpeg public API).
|
||||||
let ten_bit = unsafe {
|
let ten_bit = unsafe {
|
||||||
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
let desc = ffmpeg::ffi::av_pix_fmt_desc_get(fmt.into());
|
||||||
@@ -356,6 +364,7 @@ impl SoftwareDecoder {
|
|||||||
uv_stride: conv.stride(1),
|
uv_stride: conv.stride(1),
|
||||||
ten_bit,
|
ten_bit,
|
||||||
hdr,
|
hdr,
|
||||||
|
color,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,8 +595,9 @@ impl D3d11vaDecoder {
|
|||||||
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
if (*self.frame).format != ffi::AVPixelFormat::AV_PIX_FMT_D3D11 as i32 {
|
||||||
bail!("decoder returned a software frame (no D3D11 surface)");
|
bail!("decoder returned a software frame (no D3D11 surface)");
|
||||||
}
|
}
|
||||||
let hdr =
|
// SAFETY: `self.frame` is the live decoded AVFrame for the duration of this call.
|
||||||
(*self.frame).color_trc == ffi::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084;
|
let color = ColorDesc::from_raw(self.frame);
|
||||||
|
let hdr = color.is_pq();
|
||||||
let ten_bit = {
|
let ten_bit = {
|
||||||
let hwfc = (*self.frame).hw_frames_ctx;
|
let hwfc = (*self.frame).hw_frames_ctx;
|
||||||
!hwfc.is_null()
|
!hwfc.is_null()
|
||||||
@@ -604,6 +614,7 @@ impl D3d11vaDecoder {
|
|||||||
index: (*self.frame).data[1] as usize as u32,
|
index: (*self.frame).data[1] as usize as u32,
|
||||||
ten_bit,
|
ten_bit,
|
||||||
hdr,
|
hdr,
|
||||||
|
color,
|
||||||
guard: D3d11FrameGuard(cloned),
|
guard: D3d11FrameGuard(cloned),
|
||||||
};
|
};
|
||||||
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
log_layout_once(frame.width, frame.height, frame.index, hdr, ten_bit);
|
||||||
|
|||||||
@@ -279,7 +279,9 @@ fn pump(
|
|||||||
})
|
})
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let clock_offset = connector.clock_offset_ns;
|
// Live host↔client clock offset: loaded per frame (Relaxed) so mid-stream re-syncs (an NTP
|
||||||
|
// step, drift) keep the capture-clock latency stats honest — never cached at session start.
|
||||||
|
let clock_offset_live = connector.clock_offset_shared();
|
||||||
let mut total_frames = 0u64;
|
let mut total_frames = 0u64;
|
||||||
let mut window_start = Instant::now();
|
let mut window_start = Instant::now();
|
||||||
let mut frames_n = 0u32;
|
let mut frames_n = 0u32;
|
||||||
@@ -352,6 +354,8 @@ fn pump(
|
|||||||
let decoded_ns = now_ns();
|
let decoded_ns = now_ns();
|
||||||
// `host+network` stage: received expressed in the host's capture
|
// `host+network` stage: received expressed in the host's capture
|
||||||
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
|
// clock, minus the host-stamped capture pts (clamped (0, 10 s)).
|
||||||
|
let clock_offset =
|
||||||
|
clock_offset_live.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
let hn = (received_ns as i128 + clock_offset as i128 - frame.pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if hn > 0 && hn < 10_000_000_000 {
|
if hn > 0 && hn < 10_000_000_000 {
|
||||||
@@ -562,18 +566,37 @@ fn spawn_audio(
|
|||||||
.name("punktfunk-audio-rx".into())
|
.name("punktfunk-audio-rx".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
let mut pcm = vec![0f32; 5760 * channels as usize]; // scratch: max Opus frame (120 ms) × channels
|
||||||
|
let mut gaps = punktfunk_core::audio::AudioGapTracker::new();
|
||||||
|
let mut frame_samples = 0usize; // per-channel samples of the last decoded frame — the PLC unit
|
||||||
while !stop.load(Ordering::SeqCst) {
|
while !stop.load(Ordering::SeqCst) {
|
||||||
match connector.next_audio(Duration::from_millis(100)) {
|
match connector.next_audio(Duration::from_millis(100)) {
|
||||||
Ok(pkt) => match dec.decode_float(&pkt.data, &mut pcm, false) {
|
Ok(pkt) => {
|
||||||
|
// Conceal lost packets (a seq gap) with libopus PLC before decoding the one
|
||||||
|
// that arrived: empty input synthesizes `frame_samples` of interpolation per
|
||||||
|
// missing packet — an inaudible fade instead of the click a hard gap makes.
|
||||||
|
for _ in 0..gaps.missing_before(pkt.seq) {
|
||||||
|
let plc = frame_samples * channels as usize;
|
||||||
|
if plc == 0 {
|
||||||
|
break; // no decoded frame yet to size the concealment from
|
||||||
|
}
|
||||||
|
if let Ok(samples) = dec.decode_float(&[], &mut pcm[..plc], false) {
|
||||||
|
let mut buf = player.take_buffer();
|
||||||
|
buf.extend_from_slice(&pcm[..samples * channels as usize]);
|
||||||
|
player.push(buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match dec.decode_float(&pkt.data, &mut pcm, false) {
|
||||||
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
// `samples` is per-channel; the interleaved frame is `samples * channels`.
|
||||||
Ok(samples) => {
|
Ok(samples) => {
|
||||||
|
frame_samples = samples;
|
||||||
let n = samples * channels as usize;
|
let n = samples * channels as usize;
|
||||||
let mut buf = player.take_buffer();
|
let mut buf = player.take_buffer();
|
||||||
buf.extend_from_slice(&pcm[..n]);
|
buf.extend_from_slice(&pcm[..n]);
|
||||||
player.push(buf);
|
player.push(buf);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
Err(e) => tracing::debug!(error = %e, "opus decode"),
|
||||||
},
|
}
|
||||||
|
}
|
||||||
Err(PunktfunkError::NoFrame) => {}
|
Err(PunktfunkError::NoFrame) => {}
|
||||||
Err(_) => break, // plane closed — the session is ending
|
Err(_) => break, // plane closed — the session is ending
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,11 +119,13 @@ pub struct ColorDesc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ColorDesc {
|
impl ColorDesc {
|
||||||
/// Read the CICP fields off a raw decoded frame.
|
/// Read the CICP fields off a raw decoded frame. Public: the Windows client's raw-FFI
|
||||||
|
/// D3D11VA/software decoders build their per-frame `ColorDesc` with it too (same
|
||||||
|
/// `ffmpeg-next` major, so the `AVFrame` type unifies across the workspace).
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
/// `frame` must point to a valid `AVFrame` (alive for the duration of the call).
|
||||||
pub(crate) unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
pub unsafe fn from_raw(frame: *const ffmpeg::ffi::AVFrame) -> ColorDesc {
|
||||||
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
// SAFETY: caller guarantees a live AVFrame; these are plain enum field reads.
|
||||||
unsafe {
|
unsafe {
|
||||||
ColorDesc {
|
ColorDesc {
|
||||||
@@ -141,6 +143,57 @@ impl ColorDesc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Y′CbCr→RGB conversion as three vec4 rows for a shader constant buffer / push-constant
|
||||||
|
/// block: `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact. The ONE coefficient
|
||||||
|
/// implementation every presenter derives its CSC from (Vulkan push constants, the Windows
|
||||||
|
/// client's D3D11 constant buffer), so a stream's signaled matrix/range is honored identically
|
||||||
|
/// everywhere; the Apple client ports this function (and its tests) to Swift.
|
||||||
|
///
|
||||||
|
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
|
||||||
|
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
|
||||||
|
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
||||||
|
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
||||||
|
/// `65535/65472` recovers exact `code/1023`.
|
||||||
|
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
||||||
|
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
||||||
|
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
||||||
|
let (kr, kb) = match desc.matrix {
|
||||||
|
5 | 6 => (0.299, 0.114),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
||||||
|
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
||||||
|
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
||||||
|
let (sy, oy, sc) = if desc.full_range {
|
||||||
|
(pack, 0.0f64, pack)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
pack * max / (219.0 * step),
|
||||||
|
-(16.0 * step) / max,
|
||||||
|
pack * max / (224.0 * step),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
||||||
|
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
||||||
|
// factor to land on the same scale.
|
||||||
|
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
|
||||||
|
let m = [
|
||||||
|
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
||||||
|
[
|
||||||
|
sy,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
],
|
||||||
|
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
||||||
|
];
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
||||||
|
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
|
||||||
pub struct CpuFrame {
|
pub struct CpuFrame {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
@@ -1387,6 +1440,117 @@ unsafe extern "C" fn pick_vulkan(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix,
|
||||||
|
full_range,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
||||||
|
core::array::from_fn(|r| {
|
||||||
|
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
||||||
|
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
||||||
|
#[test]
|
||||||
|
fn bt2020_10bit_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(9, false), 10, true);
|
||||||
|
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
||||||
|
let white = apply(&rows, [s(940), s(512), s(512)]);
|
||||||
|
let black = apply(&rows, [s(64), s(512), s(512)]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.002, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
||||||
|
/// — the GL presenter's test, in row form.
|
||||||
|
#[test]
|
||||||
|
fn bt709_limited_white_black() {
|
||||||
|
let rows = csc_rows(desc(1, false), 8, false);
|
||||||
|
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
||||||
|
for (w, b) in white.iter().zip(black) {
|
||||||
|
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
||||||
|
assert!(b.abs() < 0.005, "black {black:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
||||||
|
/// matrix-code dispatch), same as the GL presenter's test.
|
||||||
|
#[test]
|
||||||
|
fn full_range_and_red_excursion() {
|
||||||
|
let rows = csc_rows(desc(5, true), 8, false);
|
||||||
|
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
||||||
|
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
||||||
|
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
||||||
|
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
||||||
|
let rows709 = csc_rows(desc(1, true), 8, false);
|
||||||
|
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
||||||
|
assert!(
|
||||||
|
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
||||||
|
"{red709:?}"
|
||||||
|
);
|
||||||
|
assert!((red[0] - red709[0]).abs() > 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
||||||
|
/// grid of inputs — same math, different packing.
|
||||||
|
#[test]
|
||||||
|
fn rows_match_the_gl_matrix_form() {
|
||||||
|
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
||||||
|
let d = desc(matrix, full);
|
||||||
|
let rows = csc_rows(d, 8, false);
|
||||||
|
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
||||||
|
let (kr, kb) = match matrix {
|
||||||
|
5 | 6 => (0.299f32, 0.114f32),
|
||||||
|
9 | 10 => (0.2627, 0.0593),
|
||||||
|
_ => (0.2126, 0.0722),
|
||||||
|
};
|
||||||
|
let kg = 1.0 - kr - kb;
|
||||||
|
let (sy, oy, sc) = if full {
|
||||||
|
(1.0f32, 0.0f32, 1.0f32)
|
||||||
|
} else {
|
||||||
|
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
||||||
|
};
|
||||||
|
let mat = [
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
sy,
|
||||||
|
0.0,
|
||||||
|
-2.0 * (1.0 - kb) * kb / kg * sc,
|
||||||
|
2.0 * (1.0 - kb) * sc,
|
||||||
|
2.0 * (1.0 - kr) * sc,
|
||||||
|
-2.0 * (1.0 - kr) * kr / kg * sc,
|
||||||
|
0.0,
|
||||||
|
];
|
||||||
|
let off = [oy, -0.5, -0.5];
|
||||||
|
for yuv in [
|
||||||
|
[0.1f32, 0.3, 0.7],
|
||||||
|
[0.9, 0.5, 0.5],
|
||||||
|
[0.5, 0.2, 0.8],
|
||||||
|
[16.0 / 255.0, 0.5, 0.5],
|
||||||
|
] {
|
||||||
|
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
||||||
|
let gl: [f32; 3] =
|
||||||
|
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
||||||
|
let ours = apply(&rows, yuv);
|
||||||
|
for (a, b) in gl.iter().zip(ours) {
|
||||||
|
assert!(
|
||||||
|
(a - b).abs() < 1e-5,
|
||||||
|
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||||||
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1434,4 +1598,82 @@ mod tests {
|
|||||||
assert!(f.color.is_pq());
|
assert!(f.color.is_pq());
|
||||||
assert_eq!((f.width, f.height), (64, 64));
|
assert_eq!((f.width, f.height), (64, 64));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Golden colour fixtures: one 256×64 LOSSLESS x265 IDR of 8 fully-saturated colour bars per
|
||||||
|
/// signaling variant (generated offline with ffmpeg/libx265; the RGB→YUV conversion matched
|
||||||
|
/// to the VUI each fixture declares, so the original RGB is recoverable ±1 code). Decoding
|
||||||
|
/// through the real CPU path (`SoftwareDecoder` → per-frame `ColorDesc` → swscale with the
|
||||||
|
/// signaled matrix/range) must reproduce the bars — the end-to-end guard for the
|
||||||
|
/// signaling-driven CSC across BT.601/709 × limited/full. A hardcoded-709 regression fails
|
||||||
|
/// the 601 fixture by tens of code points; a range mix-up fails the full-range one.
|
||||||
|
#[test]
|
||||||
|
fn software_decode_reproduces_golden_bars() {
|
||||||
|
const BARS: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255),
|
||||||
|
(255, 255, 0),
|
||||||
|
(0, 255, 255),
|
||||||
|
(0, 255, 0),
|
||||||
|
(255, 0, 255),
|
||||||
|
(255, 0, 0),
|
||||||
|
(0, 0, 255),
|
||||||
|
(0, 0, 0),
|
||||||
|
];
|
||||||
|
let fixtures: [(&str, &[u8], ColorDesc); 3] = [
|
||||||
|
(
|
||||||
|
"601-limited",
|
||||||
|
include_bytes!("../tests/bars-601-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 5, // BT.470BG — what a Linux host's RGB-input NVENC signals
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-limited",
|
||||||
|
include_bytes!("../tests/bars-709-limited.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"709-full",
|
||||||
|
include_bytes!("../tests/bars-709-full.h265"),
|
||||||
|
ColorDesc {
|
||||||
|
primaries: 1,
|
||||||
|
transfer: 1,
|
||||||
|
matrix: 1,
|
||||||
|
full_range: true, // the PUNKTFUNK_444_FULLRANGE experiment's signaling
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, au, want_color) in fixtures {
|
||||||
|
let mut dec = SoftwareDecoder::new(ffmpeg::codec::Id::HEVC).expect("hevc decoder");
|
||||||
|
let mut got = dec.decode(au).expect("decode");
|
||||||
|
if got.is_none() {
|
||||||
|
dec.decoder.send_eof().ok();
|
||||||
|
let mut frame = AvFrame::empty();
|
||||||
|
if dec.decoder.receive_frame(&mut frame).is_ok() {
|
||||||
|
got = Some(dec.convert_rgba(&frame).expect("convert"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let f = got.unwrap_or_else(|| panic!("{name}: no frame decoded"));
|
||||||
|
assert_eq!(f.color, want_color, "{name}: signaling");
|
||||||
|
assert_eq!((f.width, f.height), (256, 64), "{name}: dims");
|
||||||
|
for (i, (r, g, b)) in BARS.iter().enumerate() {
|
||||||
|
let (cx, cy) = (i * 32 + 16, 32usize);
|
||||||
|
let o = cy * f.stride + cx * 4;
|
||||||
|
let px = &f.rgba[o..o + 3];
|
||||||
|
for (got, want) in px.iter().zip([r, g, b]) {
|
||||||
|
assert!(
|
||||||
|
got.abs_diff(*want) <= 3,
|
||||||
|
"{name} bar {i}: got {px:?}, want ({r},{g},{b})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ use windows::Win32::Graphics::Direct3D11::{
|
|||||||
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
D3D11_VPOV_DIMENSION_TEXTURE2D,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::Common::{
|
use windows::Win32::Graphics::Dxgi::Common::{
|
||||||
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||||
|
DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||||
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709, DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM,
|
DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601, DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||||
DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL, DXGI_SAMPLE_DESC,
|
DXGI_FORMAT, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_NV12, DXGI_FORMAT_P010, DXGI_RATIONAL,
|
||||||
|
DXGI_SAMPLE_DESC,
|
||||||
};
|
};
|
||||||
use windows::Win32::Graphics::Dxgi::{
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
|
CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory1, IDXGIKeyedMutex, IDXGIResource1,
|
||||||
@@ -629,9 +631,16 @@ impl D3d11vaDecoder {
|
|||||||
|
|
||||||
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
|
// Colour spaces per frame (the host flips PQ in-band): YCbCr in, sRGB out — a PQ
|
||||||
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
|
// stream is tone-mapped to SDR by the processor (module docs). CICP → DXGI enums.
|
||||||
|
// BT.601 (5/6) matters in practice: a Linux host's RGB-input NVENC paths signal
|
||||||
|
// BT470BG limited (NVENC's fixed internal RGB→YUV is BT.601 — ffmpeg force-writes
|
||||||
|
// that VUI), and mapping it to P709 here was a constant hue error on those streams.
|
||||||
|
// DXGI has no full-range G2084 YCbCr enum, so PQ is studio regardless of range.
|
||||||
let in_cs = match (color.transfer, color.matrix, color.full_range) {
|
let in_cs = match (color.transfer, color.matrix, color.full_range) {
|
||||||
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
(16, _, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G2084_LEFT_P2020,
|
||||||
(_, 9, _) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
(_, 9 | 10, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P2020,
|
||||||
|
(_, 9 | 10, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P2020,
|
||||||
|
(_, 5 | 6, false) => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P601,
|
||||||
|
(_, 5 | 6, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P601,
|
||||||
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
(_, _, true) => DXGI_COLOR_SPACE_YCBCR_FULL_G22_LEFT_P709,
|
||||||
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
_ => DXGI_COLOR_SPACE_YCBCR_STUDIO_G22_LEFT_P709,
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -62,7 +62,17 @@ vec3 srgb_oetf(vec3 c) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, v_uv).rg);
|
// 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and
|
||||||
|
// what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER
|
||||||
|
// siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma
|
||||||
|
// texels to re-align (the same correction the Apple/Windows clients apply). Self-disables
|
||||||
|
// when the plane widths match (a full-size 4:4:4 chroma plane needs no correction).
|
||||||
|
vec2 cuv = v_uv;
|
||||||
|
int cw = textureSize(u_c, 0).x;
|
||||||
|
if (cw < textureSize(u_y, 0).x) {
|
||||||
|
cuv.x += 0.25 / float(cw);
|
||||||
|
}
|
||||||
|
vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg);
|
||||||
vec3 rgb = vec3(
|
vec3 rgb = vec3(
|
||||||
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
dot(pc.r0.xyz, yuv) + pc.r0.w,
|
||||||
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
dot(pc.r1.xyz, yuv) + pc.r1.w,
|
||||||
|
|||||||
Binary file not shown.
@@ -9,55 +9,11 @@
|
|||||||
|
|
||||||
use anyhow::{Context as _, Result};
|
use anyhow::{Context as _, Result};
|
||||||
use ash::vk;
|
use ash::vk;
|
||||||
use pf_client_core::video::ColorDesc;
|
|
||||||
|
|
||||||
/// The push-constant block's matrix half: three vec4 rows,
|
// The coefficient math lives in pf-client-core next to `ColorDesc` (one tested
|
||||||
/// `rgb[i] = dot(r[i].xyz, yuv) + r[i].w` — bit-depth exact.
|
// implementation shared with the Windows client's D3D11 constant buffer and mirrored by the
|
||||||
///
|
// Apple client's Swift port); re-exported here so presenter callers keep their import path.
|
||||||
/// `depth` picks the limited-range code points (8-bit: 16/235/240 over 255; 10-bit:
|
pub use pf_client_core::video::csc_rows;
|
||||||
/// 64/940/960 over 1023 — NOT the same normalized values, the difference is ~half a
|
|
||||||
/// code). `msb_packed` folds in the P010/X6 packing factor: 10 significant bits live in
|
|
||||||
/// the MSBs of 16, so a UNORM16 sample reads `code·64/65535` — multiplying by
|
|
||||||
/// `65535/65472` recovers exact `code/1023`.
|
|
||||||
pub fn csc_rows(desc: ColorDesc, depth: u8, msb_packed: bool) -> [[f32; 4]; 3] {
|
|
||||||
// BT.601 (5/6), BT.2020 (9/10); everything else — incl. unspecified — is the host's
|
|
||||||
// BT.709 SDR default (mirrors the software path's swscale coefficient choice).
|
|
||||||
let (kr, kb) = match desc.matrix {
|
|
||||||
5 | 6 => (0.299, 0.114),
|
|
||||||
9 | 10 => (0.2627, 0.0593),
|
|
||||||
_ => (0.2126, 0.0722),
|
|
||||||
};
|
|
||||||
let kg = 1.0 - kr - kb;
|
|
||||||
let max = f64::from((1u32 << depth) - 1); // 255 / 1023
|
|
||||||
let step = f64::from(1u32 << (depth - 8)); // code points per 8-bit step: 1 / 4
|
|
||||||
let pack = if msb_packed { 65535.0 / 65472.0 } else { 1.0 };
|
|
||||||
let (sy, oy, sc) = if desc.full_range {
|
|
||||||
(pack, 0.0f64, pack)
|
|
||||||
} else {
|
|
||||||
(
|
|
||||||
pack * max / (219.0 * step),
|
|
||||||
-(16.0 * step) / max,
|
|
||||||
pack * max / (224.0 * step),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
// rgb = M * (yuv + off) = M*yuv + M*off — rows of M with the offset dot folded into
|
|
||||||
// w. `yuv` is the SAMPLED (packed) value, so the offsets divide by the packing
|
|
||||||
// factor to land on the same scale.
|
|
||||||
let off = [oy / pack, -0.5 / pack, -0.5 / pack];
|
|
||||||
let m = [
|
|
||||||
[sy, 0.0, 2.0 * (1.0 - kr) * sc],
|
|
||||||
[
|
|
||||||
sy,
|
|
||||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
|
||||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
|
||||||
],
|
|
||||||
[sy, 2.0 * (1.0 - kb) * sc, 0.0],
|
|
||||||
];
|
|
||||||
core::array::from_fn(|r| {
|
|
||||||
let w: f64 = (0..3).map(|c| m[r][c] * off[c]).sum();
|
|
||||||
[m[r][0] as f32, m[r][1] as f32, m[r][2] as f32, w as f32]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
/// The pass objects (everything except the per-video-size framebuffer, which lives with
|
||||||
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
/// the video image). Destroyed explicitly via [`CscPass::destroy`] from the presenter's
|
||||||
@@ -336,119 +292,3 @@ pub(crate) fn build_fullscreen_pipeline(
|
|||||||
}
|
}
|
||||||
Ok(pipeline?[0])
|
Ok(pipeline?[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
|
|
||||||
ColorDesc {
|
|
||||||
primaries: 1,
|
|
||||||
transfer: 1,
|
|
||||||
matrix,
|
|
||||||
full_range,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn apply(rows: &[[f32; 4]; 3], yuv: [f32; 3]) -> [f32; 3] {
|
|
||||||
core::array::from_fn(|r| {
|
|
||||||
rows[r][0] * yuv[0] + rows[r][1] * yuv[1] + rows[r][2] * yuv[2] + rows[r][3]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 10-bit limited MSB-packed (P010/X6): reference white Y=940, black Y=64, neutral
|
|
||||||
/// chroma 512 — sampled as UNORM16 of `code << 6`.
|
|
||||||
#[test]
|
|
||||||
fn bt2020_10bit_limited_white_black() {
|
|
||||||
let rows = csc_rows(desc(9, false), 10, true);
|
|
||||||
let s = |code: u32| ((code << 6) as f32) / 65535.0;
|
|
||||||
let white = apply(&rows, [s(940), s(512), s(512)]);
|
|
||||||
let black = apply(&rows, [s(64), s(512), s(512)]);
|
|
||||||
for (w, b) in white.iter().zip(black) {
|
|
||||||
assert!((w - 1.0).abs() < 0.002, "white {white:?}");
|
|
||||||
assert!(b.abs() < 0.002, "black {black:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reference white (Y=235, U=V=128 limited) → RGB 1.0; reference black (Y=16) → 0.0
|
|
||||||
/// — the GL presenter's test, in row form.
|
|
||||||
#[test]
|
|
||||||
fn bt709_limited_white_black() {
|
|
||||||
let rows = csc_rows(desc(1, false), 8, false);
|
|
||||||
let white = apply(&rows, [235.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
let black = apply(&rows, [16.0 / 255.0, 128.0 / 255.0, 128.0 / 255.0]);
|
|
||||||
for (w, b) in white.iter().zip(black) {
|
|
||||||
assert!((w - 1.0).abs() < 0.005, "white {white:?}");
|
|
||||||
assert!(b.abs() < 0.005, "black {black:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Full-range identity points + the 601-vs-709 red excursion (guards the
|
|
||||||
/// matrix-code dispatch), same as the GL presenter's test.
|
|
||||||
#[test]
|
|
||||||
fn full_range_and_red_excursion() {
|
|
||||||
let rows = csc_rows(desc(5, true), 8, false);
|
|
||||||
let white = apply(&rows, [1.0, 0.5, 0.5]);
|
|
||||||
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-5), "{white:?}");
|
|
||||||
let red = apply(&rows, [0.0, 0.5, 1.0]);
|
|
||||||
assert!((red[0] - 2.0 * (1.0 - 0.299) * 0.5).abs() < 1e-4, "{red:?}");
|
|
||||||
let rows709 = csc_rows(desc(1, true), 8, false);
|
|
||||||
let red709 = apply(&rows709, [0.0, 0.5, 1.0]);
|
|
||||||
assert!(
|
|
||||||
(red709[0] - 2.0 * (1.0 - 0.2126) * 0.5).abs() < 1e-4,
|
|
||||||
"{red709:?}"
|
|
||||||
);
|
|
||||||
assert!((red[0] - red709[0]).abs() > 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The row form must agree with the GL presenter's column-major `yuv_to_rgb` on a
|
|
||||||
/// grid of inputs — same math, different packing.
|
|
||||||
#[test]
|
|
||||||
fn rows_match_the_gl_matrix_form() {
|
|
||||||
for (matrix, full) in [(1u8, false), (1, true), (5, false), (9, false), (9, true)] {
|
|
||||||
let d = desc(matrix, full);
|
|
||||||
let rows = csc_rows(d, 8, false);
|
|
||||||
// Reimplementation of video_gl::yuv_to_rgb's application for comparison.
|
|
||||||
let (kr, kb) = match matrix {
|
|
||||||
5 | 6 => (0.299f32, 0.114f32),
|
|
||||||
9 | 10 => (0.2627, 0.0593),
|
|
||||||
_ => (0.2126, 0.0722),
|
|
||||||
};
|
|
||||||
let kg = 1.0 - kr - kb;
|
|
||||||
let (sy, oy, sc) = if full {
|
|
||||||
(1.0f32, 0.0f32, 1.0f32)
|
|
||||||
} else {
|
|
||||||
(255.0 / 219.0, -16.0 / 255.0, 255.0 / 224.0)
|
|
||||||
};
|
|
||||||
let mat = [
|
|
||||||
sy,
|
|
||||||
sy,
|
|
||||||
sy,
|
|
||||||
0.0,
|
|
||||||
-2.0 * (1.0 - kb) * kb / kg * sc,
|
|
||||||
2.0 * (1.0 - kb) * sc,
|
|
||||||
2.0 * (1.0 - kr) * sc,
|
|
||||||
-2.0 * (1.0 - kr) * kr / kg * sc,
|
|
||||||
0.0,
|
|
||||||
];
|
|
||||||
let off = [oy, -0.5, -0.5];
|
|
||||||
for yuv in [
|
|
||||||
[0.1f32, 0.3, 0.7],
|
|
||||||
[0.9, 0.5, 0.5],
|
|
||||||
[0.5, 0.2, 0.8],
|
|
||||||
[16.0 / 255.0, 0.5, 0.5],
|
|
||||||
] {
|
|
||||||
let v = [yuv[0] + off[0], yuv[1] + off[1], yuv[2] + off[2]];
|
|
||||||
let gl: [f32; 3] =
|
|
||||||
core::array::from_fn(|r| (0..3).map(|c| mat[c * 3 + r] * v[c]).sum());
|
|
||||||
let ours = apply(&rows, yuv);
|
|
||||||
for (a, b) in gl.iter().zip(ours) {
|
|
||||||
assert!(
|
|
||||||
(a - b).abs() < 1e-5,
|
|
||||||
"{matrix}/{full}: gl {gl:?} rows {ours:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ struct StreamState {
|
|||||||
canceled: bool,
|
canceled: bool,
|
||||||
ready_announced: bool,
|
ready_announced: bool,
|
||||||
mode_line: String,
|
mode_line: String,
|
||||||
clock_offset_ns: i64,
|
/// Live host↔client clock offset handle (None until Connected): loaded per present so
|
||||||
|
/// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift.
|
||||||
|
clock_offset: Option<Arc<std::sync::atomic::AtomicI64>>,
|
||||||
hdr: bool,
|
hdr: bool,
|
||||||
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
// Presenter-side 1 s window (design/stats-unification.md): end-to-end
|
||||||
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
// capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50.
|
||||||
@@ -205,7 +207,7 @@ impl StreamState {
|
|||||||
canceled: false,
|
canceled: false,
|
||||||
ready_announced: false,
|
ready_announced: false,
|
||||||
mode_line: String::new(),
|
mode_line: String::new(),
|
||||||
clock_offset_ns: 0,
|
clock_offset: None,
|
||||||
hdr: false,
|
hdr: false,
|
||||||
win_e2e_us: Vec::with_capacity(256),
|
win_e2e_us: Vec::with_capacity(256),
|
||||||
win_disp_us: Vec::with_capacity(256),
|
win_disp_us: Vec::with_capacity(256),
|
||||||
@@ -657,7 +659,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
|
.set_title(&format!("{} · {}", opts.window_title, st.mode_line))
|
||||||
.ok();
|
.ok();
|
||||||
gamepad.attach(c.clone());
|
gamepad.attach(c.clone());
|
||||||
st.clock_offset_ns = c.clock_offset_ns;
|
st.clock_offset = Some(c.clock_offset_shared());
|
||||||
let mut cap = Capture::new(c.clone());
|
let mut cap = Capture::new(c.clone());
|
||||||
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
cap.engage(); // capture engages when the stream starts (ui_stream parity)
|
||||||
apply_capture(&mut window, &mouse, true);
|
apply_capture(&mut window, &mouse, true);
|
||||||
@@ -960,7 +962,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
|||||||
println!("{{\"ready\":true}}");
|
println!("{{\"ready\":true}}");
|
||||||
}
|
}
|
||||||
// The `displayed` stamp (same clamp rules as the pump's windows).
|
// The `displayed` stamp (same clamp rules as the pump's windows).
|
||||||
let e2e = (displayed_ns as i128 + st.clock_offset_ns as i128 - pts_ns as i128)
|
let clock_offset_ns = st
|
||||||
|
.clock_offset
|
||||||
|
.as_ref()
|
||||||
|
.map_or(0, |o| o.load(Ordering::Relaxed));
|
||||||
|
let e2e = (displayed_ns as i128 + clock_offset_ns as i128 - pts_ns as i128)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
if e2e > 0 && e2e < 10_000_000_000 {
|
if e2e > 0 && e2e < 10_000_000_000 {
|
||||||
st.win_e2e_us.push(e2e / 1000);
|
st.win_e2e_us.push(e2e / 1000);
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ libc = "0.2"
|
|||||||
# windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the
|
# windows-sys (raw FFI, the quinn-udp choice): the high-level `windows` crate doesn't bind the
|
||||||
# `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR.
|
# `WSASendMsg` extension function. WinSock feature gives WSASendMsg + WSAMSG/WSABUF/CMSGHDR.
|
||||||
# Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature.
|
# Win32_System_IO too: WSASendMsg's signature references OVERLAPPED, so it's gated on that feature.
|
||||||
windows-sys = { version = "0.59", features = ["Win32_Networking_WinSock", "Win32_System_IO"] }
|
# Win32_NetworkManagement_QoS + Win32_Foundation: the qWAVE flow API for real on-the-wire DSCP
|
||||||
|
# marking (transport/qos_windows.rs) — plain IP_TOS is stripped by the Windows stack.
|
||||||
|
windows-sys = { version = "0.59", features = [
|
||||||
|
"Win32_Networking_WinSock",
|
||||||
|
"Win32_System_IO",
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_NetworkManagement_QoS",
|
||||||
|
] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ fn bench_crypto(c: &mut Criterion) {
|
|||||||
g.bench_function("open", |b| {
|
g.bench_function("open", |b| {
|
||||||
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
|
b.iter(|| black_box(client.open(0, black_box(&sealed)).unwrap()))
|
||||||
});
|
});
|
||||||
|
g.bench_function("open_in_place", |b| {
|
||||||
|
// In-place open consumes the buffer, so each iteration restores the ciphertext first —
|
||||||
|
// one memcpy, mirroring what the recv ring does when the next datagram lands in the slot.
|
||||||
|
let mut buf = sealed.clone();
|
||||||
|
b.iter(|| {
|
||||||
|
buf.copy_from_slice(black_box(&sealed));
|
||||||
|
black_box(client.open_in_place(0, &mut buf).unwrap());
|
||||||
|
})
|
||||||
|
});
|
||||||
g.finish();
|
g.finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,54 @@ pub fn normalize_channels(requested: u8) -> u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loss detector for the client audio plane, shared by every platform decoder.
|
||||||
|
///
|
||||||
|
/// The `0xC9` audio datagrams carry a per-packet sequence the host advances by 1 (wrapping), but
|
||||||
|
/// ride the lossy datagram plane with no FEC — a lost 5 ms Opus packet used to play out as a hard
|
||||||
|
/// gap (a click/pop; the jitter rings just emit silence). Feeding this tracker each received
|
||||||
|
/// packet's sequence tells the decoder how many packets went missing *immediately before it*, so
|
||||||
|
/// it can synthesize that many frames of libopus packet-loss concealment (`decode` with empty
|
||||||
|
/// input) before decoding the real one — turning clicks into an inaudible interpolation.
|
||||||
|
///
|
||||||
|
/// Reorders and duplicates conceal nothing (the plane has no reorder buffer; playing a late
|
||||||
|
/// packet where it lands is the existing behaviour), and a gap is capped at
|
||||||
|
/// [`MAX_CONCEAL_PACKETS`] (50 ms at the protocol's 5 ms frames) — libopus PLC fades to silence
|
||||||
|
/// after a few frames anyway, so past the cap the ring's underrun/re-prime path takes over as
|
||||||
|
/// before.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct AudioGapTracker {
|
||||||
|
/// Sequence of the newest packet seen (`None` until the first).
|
||||||
|
last_seq: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Most packets a single gap will ask concealment for (50 ms at the protocol's 5 ms frames).
|
||||||
|
/// Crate-internal: callers only ever see `missing_before`'s already-capped count (and cbindgen
|
||||||
|
/// must not export it — it's not part of the C ABI).
|
||||||
|
const MAX_CONCEAL_PACKETS: u32 = 10;
|
||||||
|
|
||||||
|
impl AudioGapTracker {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed the next received packet's sequence; returns how many packets are missing immediately
|
||||||
|
/// before it (`0` for in-order, the first packet, duplicates, and reorders), capped at
|
||||||
|
/// [`MAX_CONCEAL_PACKETS`]. Wrapping-safe: a sequence in the backward half of the u32 space is
|
||||||
|
/// a reorder, not a 2³¹-packet gap.
|
||||||
|
pub fn missing_before(&mut self, seq: u32) -> u32 {
|
||||||
|
let Some(last) = self.last_seq else {
|
||||||
|
self.last_seq = Some(seq);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
let delta = seq.wrapping_sub(last);
|
||||||
|
if delta == 0 || delta > u32::MAX / 2 {
|
||||||
|
return 0; // duplicate, or a reorder older than the newest — nothing to conceal
|
||||||
|
}
|
||||||
|
self.last_seq = Some(seq);
|
||||||
|
(delta - 1).min(MAX_CONCEAL_PACKETS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||||
|
|
||||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||||
@@ -215,6 +263,29 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gap_tracker_counts_only_forward_gaps() {
|
||||||
|
let mut t = AudioGapTracker::new();
|
||||||
|
assert_eq!(t.missing_before(100), 0, "first packet");
|
||||||
|
assert_eq!(t.missing_before(101), 0, "in order");
|
||||||
|
assert_eq!(t.missing_before(104), 2, "102+103 lost");
|
||||||
|
assert_eq!(t.missing_before(104), 0, "duplicate");
|
||||||
|
assert_eq!(t.missing_before(103), 0, "late reorder conceals nothing");
|
||||||
|
assert_eq!(t.missing_before(105), 0, "reorder didn't move the anchor");
|
||||||
|
// A huge gap is capped; the stream continues from the new anchor.
|
||||||
|
assert_eq!(t.missing_before(105 + 1000), MAX_CONCEAL_PACKETS);
|
||||||
|
assert_eq!(t.missing_before(105 + 1001), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gap_tracker_survives_seq_wraparound() {
|
||||||
|
let mut t = AudioGapTracker::new();
|
||||||
|
assert_eq!(t.missing_before(u32::MAX - 1), 0);
|
||||||
|
assert_eq!(t.missing_before(u32::MAX), 0, "in order at the edge");
|
||||||
|
assert_eq!(t.missing_before(1), 1, "seq 0 lost across the wrap");
|
||||||
|
assert_eq!(t.missing_before(0), 0, "pre-wrap reorder, not a 2^31 gap");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wasapi_masks_are_correct() {
|
fn wasapi_masks_are_correct() {
|
||||||
assert_eq!(wasapi_channel_mask(2), 0x3);
|
assert_eq!(wasapi_channel_mask(2), 0x3);
|
||||||
|
|||||||
+299
-111
@@ -17,14 +17,14 @@ use crate::error::{PunktfunkError, Result};
|
|||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::packet::FLAG_PROBE;
|
use crate::packet::FLAG_PROBE;
|
||||||
use crate::quic::{
|
use crate::quic::{
|
||||||
endpoint, io, window_loss_ppm, BitrateChanged, ColorInfo, HdrMeta, Hello, HidOutput,
|
accept_resync, endpoint, io, wall_clock_ns, window_loss_ppm, BitrateChanged, ClockEcho,
|
||||||
LossReport, ProbeRequest, ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RichInput,
|
ClockResync, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
||||||
SetBitrate, Start, Welcome,
|
Reconfigure, Reconfigured, RequestKeyframe, ResyncStep, RichInput, SetBitrate, Start, Welcome,
|
||||||
};
|
};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, SyncSender};
|
||||||
use std::sync::{Arc, Condvar, Mutex};
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -53,28 +53,42 @@ enum CtrlRequest {
|
|||||||
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
/// Adaptive bitrate: ask the host to re-target its encoder (kbps). Sent by the pump's
|
||||||
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
/// [`BitrateController`] when the user's bitrate setting is Automatic.
|
||||||
SetBitrate(u32),
|
SetBitrate(u32),
|
||||||
|
/// Start a mid-stream clock re-sync batch now (see [`ClockResync`]). Sent by the pump on
|
||||||
|
/// its report tick after the first no-op clock flush — the "the clock stepped under me"
|
||||||
|
/// signal; the control task also self-triggers one every [`CLOCK_RESYNC_INTERVAL`].
|
||||||
|
ClockResync,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the negotiated
|
/// What the worker reports to [`NativeClient::connect`] once the handshake lands: the
|
||||||
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's
|
/// [`Welcome`]-resolved session parameters (mode, backends, encode/colour/audio geometry) plus the
|
||||||
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
|
/// host certificate fingerprint and the connect-time clock offset. Mirrored one-to-one onto the
|
||||||
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
|
/// public `NativeClient` fields of the same names.
|
||||||
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc`
|
#[derive(Clone, Copy)]
|
||||||
/// (1 = 4:2:0, 3 = 4:4:4), the resolved audio channel count (2/6/8), and the resolved video codec
|
struct Negotiated {
|
||||||
/// (`quic::CODEC_*`), with [`ColorInfo`] the resolved colour signalling — all from the [`Welcome`].
|
mode: Mode,
|
||||||
type Negotiated = (
|
compositor: CompositorPref,
|
||||||
Mode,
|
gamepad: GamepadPref,
|
||||||
CompositorPref,
|
/// SHA-256 of the certificate the host actually presented (TOFU callers persist this).
|
||||||
GamepadPref,
|
host_fingerprint: [u8; 32],
|
||||||
[u8; 32],
|
/// The encoder bitrate the host actually configured (kbps); `0` = an older host.
|
||||||
u32,
|
bitrate_kbps: u32,
|
||||||
i64,
|
/// Host clock minus client clock (ns); `0` = no skew handshake (old host / synced clocks).
|
||||||
u8,
|
clock_offset_ns: i64,
|
||||||
ColorInfo,
|
/// Min RTT of the connect-time skew handshake (ns); `None` = the host never answered —
|
||||||
u8,
|
/// mid-stream re-syncs are pointless then and stay off. The re-sync acceptance guard
|
||||||
u8,
|
/// compares each batch against this baseline ([`accept_resync`]).
|
||||||
u8,
|
clock_rtt_ns: Option<u64>,
|
||||||
);
|
/// Resolved encode bit depth: `8`, or `10` for a Main10 / HDR session.
|
||||||
|
bit_depth: u8,
|
||||||
|
/// Resolved CICP colour signalling.
|
||||||
|
color: ColorInfo,
|
||||||
|
/// Resolved chroma subsampling as the HEVC `chroma_format_idc` (1 = 4:2:0, 3 = 4:4:4).
|
||||||
|
chroma_format: u8,
|
||||||
|
/// Resolved audio channel count (2/6/8) — what the Opus decoders must be built from.
|
||||||
|
audio_channels: u8,
|
||||||
|
/// The single codec the host will emit (`quic::CODEC_*`).
|
||||||
|
codec: u8,
|
||||||
|
}
|
||||||
|
|
||||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||||
/// session's packet-level receive counters here; the control task finalizes the delivered figure
|
/// session's packet-level receive counters here; the control task finalizes the delivered figure
|
||||||
@@ -85,7 +99,8 @@ type Negotiated = (
|
|||||||
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
|
/// completes, so the old AU-based count cliffed to zero even though most bytes still arrived.
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct ProbeState {
|
struct ProbeState {
|
||||||
/// A probe is in progress (set by `request_probe`, cleared by nothing — the latest one wins).
|
/// A probe is in progress: set by `request_probe`, cleared when the host's [`ProbeResult`]
|
||||||
|
/// lands (a re-probe just overwrites the whole state — the latest one wins).
|
||||||
active: bool,
|
active: bool,
|
||||||
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
|
/// `session.stats()` receive counters at the burst's start (snapshotted by the pump on its first
|
||||||
/// tick while active) and latest, mirrored every pump iteration.
|
/// tick while active) and latest, mirrored every pump iteration.
|
||||||
@@ -177,6 +192,41 @@ const FLUSH_AFTER_FRAMES: u32 = 30;
|
|||||||
/// warning instead of a continuous flush/keyframe storm.
|
/// warning instead of a continuous flush/keyframe storm.
|
||||||
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
const FLUSH_COOLDOWN: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// A clock-triggered jump-to-live that discarded fewer datagrams than this (and no queued AUs)
|
||||||
|
/// found NO local backlog: the frames read as late, but nothing here was actually behind. Two
|
||||||
|
/// causes, and flushing helps neither: a **wall-clock step** (NTP mid-session on either end)
|
||||||
|
/// shifted the skew-corrected latency by a constant — every future frame reads over-bound and the
|
||||||
|
/// detector would fire forever, one flush + recovery IDR per cooldown, dragging the bitrate
|
||||||
|
/// controller to its floor; or the delay is standing in an **upstream queue** (router bufferbloat),
|
||||||
|
/// which a local flush can't drain — the OWD signal already feeds the bitrate controller, the
|
||||||
|
/// actual remedy. Even at the 5 Mbps bitrate floor a genuine 400 ms backlog is ~170 datagrams, so
|
||||||
|
/// 64 cleanly separates "empty" from "real". See `NOOP_CLOCK_FLUSHES_TO_DISARM`.
|
||||||
|
const NOOP_FLUSH_DATAGRAMS: u64 = 64;
|
||||||
|
|
||||||
|
/// Consecutive no-op clock-triggered flushes (see [`NOOP_FLUSH_DATAGRAMS`]) before the clock-based
|
||||||
|
/// detector is disarmed. The clock-free standing-queue detector stays armed — it measures the
|
||||||
|
/// local queue directly and can't be fooled by a clock step. No longer for the rest of the
|
||||||
|
/// session: an applied mid-stream clock re-sync re-arms the detector (the disarm stays as the
|
||||||
|
/// final backstop between re-syncs).
|
||||||
|
const NOOP_CLOCK_FLUSHES_TO_DISARM: u32 = 2;
|
||||||
|
|
||||||
|
/// Cadence of the control task's periodic mid-stream clock re-sync (see [`ClockResync`]): often
|
||||||
|
/// enough to bound slow drift and pick up an NTP step within a minute, rare enough to be free
|
||||||
|
/// (8 tiny control messages per batch). The pump additionally fires one immediately after the
|
||||||
|
/// FIRST no-op clock flush — the moment a step is actually suspected.
|
||||||
|
const CLOCK_RESYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// Outbound mic uplink queue depth: 5 ms Opus frames, so 64 is ~320 ms of audio — far beyond
|
||||||
|
/// any worker stall a live mic session survives anyway. On overflow the FRESH frame is dropped
|
||||||
|
/// (a tokio mpsc can't shed from the head; by the time 320 ms are queued the stream is broken
|
||||||
|
/// either way, and the bound is about memory, not audio quality) and logged at debug.
|
||||||
|
const MIC_QUEUE: usize = 64;
|
||||||
|
|
||||||
|
/// Outbound control-request queue depth. The requests are sparse (mode switches, keyframe
|
||||||
|
/// requests, ~1.3 loss reports/s, clock re-syncs) — 32 is hours of headroom; a full queue means
|
||||||
|
/// the control task is wedged, which callers treat as a closed session.
|
||||||
|
const CTRL_QUEUE: usize = 32;
|
||||||
|
|
||||||
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
/// The pre-decode video hand-off from the data-plane pump to the embedder. Unlike the side planes
|
||||||
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
/// (self-contained samples that drop the newest on overflow), video AUs are reference-chained under the
|
||||||
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
/// host's infinite GOP: dropping ANY frame mid-stream corrupts every dependent frame until the next
|
||||||
@@ -315,11 +365,15 @@ pub struct NativeClient {
|
|||||||
host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
|
host_timing: Mutex<Receiver<crate::quic::HostTiming>>,
|
||||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
/// Bounded ([`MIC_QUEUE`]): a wedged worker drops fresh frames (logged) instead of queueing
|
||||||
|
/// audio-latency (and memory) without limit — mic is best-effort end to end.
|
||||||
|
mic_tx: tokio::sync::mpsc::Sender<(u32, u64, Vec<u8>)>,
|
||||||
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
|
/// Outbound rich input (DualSense touchpad / motion) → 0xCC datagrams by the worker.
|
||||||
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
|
rich_input_tx: tokio::sync::mpsc::UnboundedSender<RichInput>,
|
||||||
/// Outbound control-stream requests (mode switch, speed test) → the worker's control task.
|
/// Outbound control-stream requests (mode switch, speed test) → the worker's control task.
|
||||||
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
/// Bounded ([`CTRL_QUEUE`]) — the requests are sparse; a full queue means the control task
|
||||||
|
/// is wedged/dead, and callers treat it like a closed session.
|
||||||
|
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||||
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
/// Speed-test accumulator, shared with the data-plane pump + control task.
|
||||||
probe: Arc<Mutex<ProbeState>>,
|
probe: Arc<Mutex<ProbeState>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
@@ -343,6 +397,11 @@ pub struct NativeClient {
|
|||||||
/// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without
|
/// so the CPU governor keeps the whole video pipeline on fast cores. Empty on platforms without
|
||||||
/// `gettid` (see [`current_hot_tid`]).
|
/// `gettid` (see [`current_hot_tid`]).
|
||||||
hot_tids: Arc<Mutex<Vec<i32>>>,
|
hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||||
|
/// The LIVE host↔client clock offset (ns): seeded with the connect-time estimate, then kept
|
||||||
|
/// fresh by the control task's mid-stream re-syncs (every [`CLOCK_RESYNC_INTERVAL`], plus on
|
||||||
|
/// the pump's first no-op clock flush). Shared with the pump and, via
|
||||||
|
/// [`clock_offset_shared`](Self::clock_offset_shared), with embedder latency-math threads.
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
worker: Option<std::thread::JoinHandle<()>>,
|
worker: Option<std::thread::JoinHandle<()>>,
|
||||||
/// The currently active session mode (the Welcome's, then updated by every accepted
|
/// The currently active session mode (the Welcome's, then updated by every accepted
|
||||||
/// [`NativeClient::request_mode`]).
|
/// [`NativeClient::request_mode`]).
|
||||||
@@ -364,7 +423,9 @@ pub struct NativeClient {
|
|||||||
/// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local
|
/// Host clock minus client clock (ns), from the connect-time skew handshake. Add it to a local
|
||||||
/// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making
|
/// receive/present timestamp to express it in the host's capture clock (the AU `pts_ns`), making
|
||||||
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
|
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
|
||||||
/// answer, or genuinely synced clocks).
|
/// answer, or genuinely synced clocks). This is the CONNECT-TIME estimate, kept for ABI/compat;
|
||||||
|
/// ongoing latency math should read [`clock_offset_now_ns`](Self::clock_offset_now_ns), which
|
||||||
|
/// follows mid-stream re-syncs after a wall-clock step or drift.
|
||||||
pub clock_offset_ns: i64,
|
pub clock_offset_ns: i64,
|
||||||
/// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or
|
/// The encode bit depth the host resolved for this session ([`Welcome::bit_depth`]): `8`, or
|
||||||
/// `10` for a Main10 / HDR session. `8` for an older host that didn't report it.
|
/// `10` for a Main10 / HDR session. `8` for an older host that didn't report it.
|
||||||
@@ -504,9 +565,9 @@ impl NativeClient {
|
|||||||
let (host_timing_tx, host_timing_rx) =
|
let (host_timing_tx, host_timing_rx) =
|
||||||
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
std::sync::mpsc::sync_channel::<crate::quic::HostTiming>(HOST_TIMING_QUEUE);
|
||||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
||||||
let (mic_tx, mic_rx) = tokio::sync::mpsc::unbounded_channel::<(u32, u64, Vec<u8>)>();
|
let (mic_tx, mic_rx) = tokio::sync::mpsc::channel::<(u32, u64, Vec<u8>)>(MIC_QUEUE);
|
||||||
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
let (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||||
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
|
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::channel::<CtrlRequest>(CTRL_QUEUE);
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
let quit = Arc::new(AtomicBool::new(false));
|
let quit = Arc::new(AtomicBool::new(false));
|
||||||
@@ -515,6 +576,7 @@ impl NativeClient {
|
|||||||
let frames_dropped = Arc::new(AtomicU64::new(0));
|
let frames_dropped = Arc::new(AtomicU64::new(0));
|
||||||
let fec_recovered = Arc::new(AtomicU64::new(0));
|
let fec_recovered = Arc::new(AtomicU64::new(0));
|
||||||
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
let hot_tids = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let clock_offset = Arc::new(AtomicI64::new(0));
|
||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
let frame_chan_w = frame_chan.clone();
|
let frame_chan_w = frame_chan.clone();
|
||||||
@@ -525,6 +587,7 @@ impl NativeClient {
|
|||||||
let frames_dropped_w = frames_dropped.clone();
|
let frames_dropped_w = frames_dropped.clone();
|
||||||
let fec_recovered_w = fec_recovered.clone();
|
let fec_recovered_w = fec_recovered.clone();
|
||||||
let hot_tids_w = hot_tids.clone();
|
let hot_tids_w = hot_tids.clone();
|
||||||
|
let clock_offset_w = clock_offset.clone();
|
||||||
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
let ctrl_tx_pump = ctrl_tx.clone(); // the data-plane pump sends adaptive-FEC LossReports
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
.name("punktfunk-client".into())
|
.name("punktfunk-client".into())
|
||||||
@@ -577,23 +640,12 @@ impl NativeClient {
|
|||||||
frames_dropped: frames_dropped_w,
|
frames_dropped: frames_dropped_w,
|
||||||
fec_recovered: fec_recovered_w,
|
fec_recovered: fec_recovered_w,
|
||||||
hot_tids: hot_tids_w,
|
hot_tids: hot_tids_w,
|
||||||
|
clock_offset: clock_offset_w,
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.map_err(PunktfunkError::Io)?;
|
.map_err(PunktfunkError::Io)?;
|
||||||
|
|
||||||
let (
|
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||||
negotiated,
|
|
||||||
resolved_compositor,
|
|
||||||
resolved_gamepad,
|
|
||||||
fingerprint,
|
|
||||||
resolved_bitrate_kbps,
|
|
||||||
clock_offset_ns,
|
|
||||||
bit_depth,
|
|
||||||
color,
|
|
||||||
chroma_format,
|
|
||||||
audio_channels,
|
|
||||||
codec,
|
|
||||||
) = match ready_rx.recv_timeout(timeout) {
|
|
||||||
Ok(Ok(t)) => t,
|
Ok(Ok(t)) => t,
|
||||||
Ok(Err(e)) => return Err(e),
|
Ok(Err(e)) => return Err(e),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -601,7 +653,7 @@ impl NativeClient {
|
|||||||
return Err(PunktfunkError::Timeout);
|
return Err(PunktfunkError::Timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*mode_slot.lock().unwrap() = negotiated;
|
*mode_slot.lock().unwrap() = negotiated.mode;
|
||||||
Ok(NativeClient {
|
Ok(NativeClient {
|
||||||
frames: frame_chan,
|
frames: frame_chan,
|
||||||
audio: Mutex::new(audio_rx),
|
audio: Mutex::new(audio_rx),
|
||||||
@@ -620,17 +672,18 @@ impl NativeClient {
|
|||||||
frames_dropped,
|
frames_dropped,
|
||||||
fec_recovered,
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
|
clock_offset,
|
||||||
mode: mode_slot,
|
mode: mode_slot,
|
||||||
host_fingerprint: fingerprint,
|
host_fingerprint: negotiated.host_fingerprint,
|
||||||
resolved_compositor,
|
resolved_compositor: negotiated.compositor,
|
||||||
resolved_gamepad,
|
resolved_gamepad: negotiated.gamepad,
|
||||||
resolved_bitrate_kbps,
|
resolved_bitrate_kbps: negotiated.bitrate_kbps,
|
||||||
clock_offset_ns,
|
clock_offset_ns: negotiated.clock_offset_ns,
|
||||||
bit_depth,
|
bit_depth: negotiated.bit_depth,
|
||||||
color,
|
color: negotiated.color,
|
||||||
chroma_format,
|
chroma_format: negotiated.chroma_format,
|
||||||
audio_channels,
|
audio_channels: negotiated.audio_channels,
|
||||||
codec,
|
codec: negotiated.codec,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +841,7 @@ impl NativeClient {
|
|||||||
/// reflects it. A rejected request leaves the session unchanged.
|
/// reflects it. A rejected request leaves the session unchanged.
|
||||||
pub fn request_mode(&self, mode: Mode) -> Result<()> {
|
pub fn request_mode(&self, mode: Mode) -> Result<()> {
|
||||||
self.ctrl_tx
|
self.ctrl_tx
|
||||||
.send(CtrlRequest::Mode(mode))
|
.try_send(CtrlRequest::Mode(mode))
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +851,7 @@ impl NativeClient {
|
|||||||
/// lands, so requesting on every frame would flood the control stream).
|
/// lands, so requesting on every frame would flood the control stream).
|
||||||
pub fn request_keyframe(&self) -> Result<()> {
|
pub fn request_keyframe(&self) -> Result<()> {
|
||||||
self.ctrl_tx
|
self.ctrl_tx
|
||||||
.send(CtrlRequest::Keyframe)
|
.try_send(CtrlRequest::Keyframe)
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,6 +902,23 @@ impl NativeClient {
|
|||||||
self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default()
|
self.hot_tids.lock().map(|v| v.clone()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The LIVE host↔client clock offset (ns): the connect-time skew estimate, kept fresh by
|
||||||
|
/// mid-stream re-syncs (every 60 s, plus immediately when a wall-clock step is suspected).
|
||||||
|
/// Prefer this over the connect-time [`clock_offset_ns`](Self::clock_offset_ns) field for any
|
||||||
|
/// ongoing latency math — after an NTP step or slow drift the connect-time value silently
|
||||||
|
/// corrupts every capture-clock comparison. `0` = no skew handshake (old host / synced clocks).
|
||||||
|
pub fn clock_offset_now_ns(&self) -> i64 {
|
||||||
|
self.clock_offset.load(Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared handle to the live clock offset for plane threads that outlive a `&self` borrow
|
||||||
|
/// (render/display trackers). Read with [`AtomicI64::load`]`(Ordering::Relaxed)` at each use —
|
||||||
|
/// never cache the value across frames. Holding this does NOT keep the session alive (unlike
|
||||||
|
/// an `Arc<NativeClient>`, whose drop disconnects).
|
||||||
|
pub fn clock_offset_shared(&self) -> Arc<AtomicI64> {
|
||||||
|
self.clock_offset.clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
/// Start a bandwidth speed test: ask the host to burst filler over the data plane at
|
||||||
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
/// `target_kbps` of goodput for `duration_ms`, *briefly pausing video*. Non-blocking — the
|
||||||
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
/// measurement accumulates in the background; poll [`NativeClient::probe_result`] until its
|
||||||
@@ -861,7 +931,7 @@ impl NativeClient {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
self.ctrl_tx
|
self.ctrl_tx
|
||||||
.send(CtrlRequest::Probe(ProbeRequest {
|
.try_send(CtrlRequest::Probe(ProbeRequest {
|
||||||
target_kbps,
|
target_kbps,
|
||||||
duration_ms,
|
duration_ms,
|
||||||
}))
|
}))
|
||||||
@@ -1007,9 +1077,17 @@ impl NativeClient {
|
|||||||
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
/// uses them only for diagnostics). The host decodes it into a virtual microphone source.
|
||||||
/// Best-effort — like every datagram, it's dropped under loss; no retransmit.
|
/// Best-effort — like every datagram, it's dropped under loss; no retransmit.
|
||||||
pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> {
|
pub fn send_mic(&self, seq: u32, pts_ns: u64, opus: Vec<u8>) -> Result<()> {
|
||||||
self.mic_tx
|
use tokio::sync::mpsc::error::TrySendError;
|
||||||
.send((seq, pts_ns, opus))
|
match self.mic_tx.try_send((seq, pts_ns, opus)) {
|
||||||
.map_err(|_| PunktfunkError::Closed)
|
Ok(()) => Ok(()),
|
||||||
|
Err(TrySendError::Full(_)) => {
|
||||||
|
// Bounded queue full = the worker stalled for ~MIC_QUEUE x 5 ms. Shed this
|
||||||
|
// frame (mic is best-effort end to end) instead of queueing latency/memory.
|
||||||
|
tracing::debug!("mic uplink queue full — dropping frame");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(TrySendError::Closed(_)) => Err(PunktfunkError::Closed),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a
|
/// Queue one rich input event (DualSense touchpad contact or motion sample) for delivery as a
|
||||||
@@ -1061,10 +1139,10 @@ struct WorkerArgs {
|
|||||||
hdr_meta_tx: SyncSender<HdrMeta>,
|
hdr_meta_tx: SyncSender<HdrMeta>,
|
||||||
host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
host_timing_tx: SyncSender<crate::quic::HostTiming>,
|
||||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
mic_rx: tokio::sync::mpsc::Receiver<(u32, u64, Vec<u8>)>,
|
||||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||||
ctrl_rx: tokio::sync::mpsc::UnboundedReceiver<CtrlRequest>,
|
ctrl_rx: tokio::sync::mpsc::Receiver<CtrlRequest>,
|
||||||
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
|
ctrl_tx: tokio::sync::mpsc::Sender<CtrlRequest>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
|
||||||
@@ -1074,6 +1152,9 @@ struct WorkerArgs {
|
|||||||
frames_dropped: Arc<AtomicU64>,
|
frames_dropped: Arc<AtomicU64>,
|
||||||
fec_recovered: Arc<AtomicU64>,
|
fec_recovered: Arc<AtomicU64>,
|
||||||
hot_tids: Arc<Mutex<Vec<i32>>>,
|
hot_tids: Arc<Mutex<Vec<i32>>>,
|
||||||
|
/// The live clock offset (see [`NativeClient::clock_offset`]): the worker seeds it with the
|
||||||
|
/// connect-time estimate; the control task's mid-stream re-syncs update it.
|
||||||
|
clock_offset: Arc<AtomicI64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||||
@@ -1112,6 +1193,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
frames_dropped,
|
frames_dropped,
|
||||||
fec_recovered,
|
fec_recovered,
|
||||||
hot_tids,
|
hot_tids,
|
||||||
|
clock_offset,
|
||||||
} = args;
|
} = args;
|
||||||
let setup = async {
|
let setup = async {
|
||||||
let remote: std::net::SocketAddr = join_host_port(&host, port)
|
let remote: std::net::SocketAddr = join_host_port(&host, port)
|
||||||
@@ -1203,7 +1285,8 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
// it): align our clock to the host's so the embedder can express receive/present instants in
|
// it): align our clock to the host's so the embedder can express receive/present instants in
|
||||||
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
// the host's capture clock (the AU `pts_ns`). 0 ⇒ an old host that didn't answer (shared-clock
|
||||||
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
// assumption, as before). This is the substrate for glass-to-glass present-time measurement.
|
||||||
let clock_offset_ns = match crate::quic::clock_sync(&mut send, &mut recv).await {
|
let (clock_offset_ns, clock_rtt_ns) =
|
||||||
|
match crate::quic::clock_sync(&mut send, &mut recv).await {
|
||||||
Some(skew) => {
|
Some(skew) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
offset_ns = skew.offset_ns,
|
offset_ns = skew.offset_ns,
|
||||||
@@ -1211,9 +1294,9 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
rounds = skew.rounds,
|
rounds = skew.rounds,
|
||||||
"clock skew estimated (host-client)"
|
"clock skew estimated (host-client)"
|
||||||
);
|
);
|
||||||
skew.offset_ns
|
(skew.offset_ns, Some(skew.rtt_ns))
|
||||||
}
|
}
|
||||||
None => 0,
|
None => (0, None),
|
||||||
};
|
};
|
||||||
|
|
||||||
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
let host_udp = std::net::SocketAddr::new(remote.ip(), welcome.udp_port);
|
||||||
@@ -1231,58 +1314,42 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
session,
|
session,
|
||||||
send,
|
send,
|
||||||
recv,
|
recv,
|
||||||
welcome.mode,
|
Negotiated {
|
||||||
welcome.compositor,
|
mode: welcome.mode,
|
||||||
welcome.gamepad,
|
compositor: welcome.compositor,
|
||||||
fingerprint,
|
gamepad: welcome.gamepad,
|
||||||
welcome.bitrate_kbps,
|
host_fingerprint: fingerprint,
|
||||||
|
bitrate_kbps: welcome.bitrate_kbps,
|
||||||
clock_offset_ns,
|
clock_offset_ns,
|
||||||
welcome.bit_depth,
|
clock_rtt_ns,
|
||||||
welcome.color,
|
bit_depth: welcome.bit_depth,
|
||||||
welcome.chroma_format,
|
color: welcome.color,
|
||||||
welcome.audio_channels,
|
chroma_format: welcome.chroma_format,
|
||||||
welcome.codec,
|
audio_channels: welcome.audio_channels,
|
||||||
|
codec: welcome.codec,
|
||||||
|
},
|
||||||
welcome.host_caps,
|
welcome.host_caps,
|
||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
let (
|
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, host_caps) = match setup.await
|
||||||
conn,
|
{
|
||||||
mut session,
|
|
||||||
mut ctrl_send,
|
|
||||||
mut ctrl_recv,
|
|
||||||
negotiated,
|
|
||||||
resolved_compositor,
|
|
||||||
resolved_gamepad,
|
|
||||||
fingerprint,
|
|
||||||
resolved_bitrate_kbps,
|
|
||||||
clock_offset_ns,
|
|
||||||
bit_depth,
|
|
||||||
color,
|
|
||||||
chroma_format,
|
|
||||||
audio_channels,
|
|
||||||
codec,
|
|
||||||
host_caps,
|
|
||||||
) = match setup.await {
|
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = ready_tx.send(Err(e));
|
let _ = ready_tx.send(Err(e));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = ready_tx.send(Ok((
|
// Copies the pump needs after `negotiated` is handed over to `connect`.
|
||||||
negotiated,
|
let clock_rtt_ns = negotiated.clock_rtt_ns;
|
||||||
resolved_compositor,
|
let resolved_bitrate_kbps = negotiated.bitrate_kbps;
|
||||||
resolved_gamepad,
|
// Seed the live offset with the connect-time estimate BEFORE the embedder can observe the
|
||||||
fingerprint,
|
// client (ready_tx): clock_offset_now_ns() never reads a pre-handshake 0 on a skewed pair.
|
||||||
resolved_bitrate_kbps,
|
clock_offset.store(negotiated.clock_offset_ns, Ordering::Relaxed);
|
||||||
clock_offset_ns,
|
// Bumped by the control task each time a re-sync batch is APPLIED; the pump watches it to
|
||||||
bit_depth,
|
// reset its staleness counters and re-arm the clock-based jump-to-live detector.
|
||||||
color,
|
let clock_gen = Arc::new(AtomicU32::new(0));
|
||||||
chroma_format,
|
let _ = ready_tx.send(Ok(negotiated));
|
||||||
audio_channels,
|
|
||||||
codec,
|
|
||||||
)));
|
|
||||||
|
|
||||||
// Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
// Input task: embedder events → QUIC datagrams. Toward a host that advertised
|
||||||
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
// HOST_CAP_GAMEPAD_STATE, the per-transition gamepad events every embedder still emits are
|
||||||
@@ -1365,7 +1432,20 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let mode_slot = mode_slot.clone();
|
let mode_slot = mode_slot.clone();
|
||||||
let probe = probe.clone();
|
let probe = probe.clone();
|
||||||
let bitrate_ack = bitrate_ack.clone();
|
let bitrate_ack = bitrate_ack.clone();
|
||||||
|
let clock_offset = clock_offset.clone();
|
||||||
|
let clock_gen = clock_gen.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
// Mid-stream clock re-sync (see [`ClockResync`]): a batch runs every
|
||||||
|
// CLOCK_RESYNC_INTERVAL and whenever the pump asks (CtrlRequest::ClockResync after
|
||||||
|
// its first no-op clock flush). Echoes interleave with the other control replies in
|
||||||
|
// the read arm below; only when the host answered the connect-time handshake — an
|
||||||
|
// old host would just eat the probes.
|
||||||
|
let mut resync = ClockResync::new();
|
||||||
|
let mut resync_tick = tokio::time::interval_at(
|
||||||
|
tokio::time::Instant::now() + CLOCK_RESYNC_INTERVAL,
|
||||||
|
CLOCK_RESYNC_INTERVAL,
|
||||||
|
);
|
||||||
|
resync_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
req = ctrl_rx.recv() => {
|
req = ctrl_rx.recv() => {
|
||||||
@@ -1376,11 +1456,23 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
CtrlRequest::Keyframe => RequestKeyframe.encode(),
|
||||||
CtrlRequest::Loss(r) => r.encode(),
|
CtrlRequest::Loss(r) => r.encode(),
|
||||||
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
CtrlRequest::SetBitrate(k) => SetBitrate { bitrate_kbps: k }.encode(),
|
||||||
|
CtrlRequest::ClockResync => {
|
||||||
|
if clock_rtt_ns.is_none() {
|
||||||
|
continue; // no connect-time handshake — host can't answer
|
||||||
|
}
|
||||||
|
resync.begin(wall_clock_ns()).encode()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
if io::write_msg(&mut ctrl_send, &bytes).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = resync_tick.tick(), if clock_rtt_ns.is_some() => {
|
||||||
|
let probe = resync.begin(wall_clock_ns());
|
||||||
|
if io::write_msg(&mut ctrl_send, &probe.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
msg = io::read_msg(&mut ctrl_recv) => {
|
msg = io::read_msg(&mut ctrl_recv) => {
|
||||||
let Ok(msg) = msg else { break }; // stream closed
|
let Ok(msg) = msg else { break }; // stream closed
|
||||||
if let Ok(ack) = Reconfigured::decode(&msg) {
|
if let Ok(ack) = Reconfigured::decode(&msg) {
|
||||||
@@ -1404,6 +1496,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
p.host_send_dropped = result.send_dropped;
|
p.host_send_dropped = result.send_dropped;
|
||||||
p.host_duration_ms = result.duration_ms;
|
p.host_duration_ms = result.duration_ms;
|
||||||
p.done = true;
|
p.done = true;
|
||||||
|
p.active = false; // burst over — the pump stops mirroring counters
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
host_goodput_bytes = result.bytes_sent,
|
host_goodput_bytes = result.bytes_sent,
|
||||||
wire_packets_sent = result.wire_packets_sent,
|
wire_packets_sent = result.wire_packets_sent,
|
||||||
@@ -1421,6 +1514,35 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
"host re-targeted encoder bitrate"
|
"host re-targeted encoder bitrate"
|
||||||
);
|
);
|
||||||
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
*bitrate_ack.lock().unwrap() = Some(ack.bitrate_kbps);
|
||||||
|
} else if let Ok(echo) = ClockEcho::decode(&msg) {
|
||||||
|
match resync.on_echo(&echo, wall_clock_ns()) {
|
||||||
|
ResyncStep::Probe(p) => {
|
||||||
|
if io::write_msg(&mut ctrl_send, &p.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||||
|
// Never let a congested window bias the offset (frames read
|
||||||
|
// late exactly then) — keep the old estimate and let the next
|
||||||
|
// periodic batch try again.
|
||||||
|
if accept_resync(rtt_ns, clock_rtt_ns.unwrap_or(0)) {
|
||||||
|
clock_offset.store(offset_ns, Ordering::Relaxed);
|
||||||
|
clock_gen.fetch_add(1, Ordering::Relaxed);
|
||||||
|
tracing::debug!(
|
||||||
|
offset_ns,
|
||||||
|
rtt_us = rtt_ns / 1000,
|
||||||
|
"mid-stream clock re-sync applied"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(
|
||||||
|
rtt_us = rtt_ns / 1000,
|
||||||
|
"clock re-sync batch discarded — RTT above the \
|
||||||
|
connect-time baseline (congested window)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ResyncStep::Idle => {}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tracing::warn!("unknown control message — ignoring");
|
tracing::warn!("unknown control message — ignoring");
|
||||||
}
|
}
|
||||||
@@ -1487,6 +1609,8 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let pump_shutdown = shutdown.clone();
|
let pump_shutdown = shutdown.clone();
|
||||||
let pump_probe = probe.clone();
|
let pump_probe = probe.clone();
|
||||||
let pump_hot_tids = hot_tids.clone();
|
let pump_hot_tids = hot_tids.clone();
|
||||||
|
let pump_clock_offset = clock_offset.clone();
|
||||||
|
let pump_clock_gen = clock_gen.clone();
|
||||||
let _ = tokio::task::spawn_blocking(move || {
|
let _ = tokio::task::spawn_blocking(move || {
|
||||||
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
pin_thread_user_interactive(); // feeds the frame channel → the user-interactive video pump
|
||||||
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
register_hot_tid(&pump_hot_tids); // this thread does UDP receive + FEC reassembly — hint it
|
||||||
@@ -1515,7 +1639,34 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let mut stale_frames: u32 = 0;
|
let mut stale_frames: u32 = 0;
|
||||||
let mut standing_frames: u32 = 0;
|
let mut standing_frames: u32 = 0;
|
||||||
let mut last_flush: Option<Instant> = None;
|
let mut last_flush: Option<Instant> = None;
|
||||||
|
// Clock-detector health: consecutive clock-triggered flushes that found no local backlog
|
||||||
|
// (see NOOP_FLUSH_DATAGRAMS). Reaching NOOP_CLOCK_FLUSHES_TO_DISARM turns the clock-based
|
||||||
|
// detector off (a clock step / upstream queue it can't fix) — until a mid-stream clock
|
||||||
|
// re-sync lands and re-arms it (`pump_clock_gen` below). The FIRST no-op flush also asks
|
||||||
|
// the control task for an immediate re-sync (via the report tick): the flush finding no
|
||||||
|
// local backlog IS the "the wall clock stepped under me" signal.
|
||||||
|
let mut noop_clock_flushes: u32 = 0;
|
||||||
|
let mut clock_detector_armed = true;
|
||||||
|
let mut resync_wanted = false;
|
||||||
|
let mut seen_clock_gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||||
while !pump_shutdown.load(Ordering::SeqCst) {
|
while !pump_shutdown.load(Ordering::SeqCst) {
|
||||||
|
// The live host↔client offset: re-loaded every iteration so an applied mid-stream
|
||||||
|
// re-sync takes effect on the very next frame's latency math.
|
||||||
|
let clock_offset_ns = pump_clock_offset.load(Ordering::Relaxed);
|
||||||
|
// An applied re-sync invalidates the staleness run measured under the OLD offset:
|
||||||
|
// reset the counters and re-arm the clock-based detector if a step had disarmed it.
|
||||||
|
let gen = pump_clock_gen.load(Ordering::Relaxed);
|
||||||
|
if gen != seen_clock_gen {
|
||||||
|
seen_clock_gen = gen;
|
||||||
|
stale_frames = 0;
|
||||||
|
noop_clock_flushes = 0;
|
||||||
|
if !clock_detector_armed {
|
||||||
|
clock_detector_armed = true;
|
||||||
|
tracing::info!(
|
||||||
|
"clock re-sync applied — clock-based jump-to-live re-armed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
// Mirror the reassembler's unrecoverable-drop count for the client's keyframe-recovery
|
||||||
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
// loop, and (during a speed test) the packet-level receive counters for the throughput
|
||||||
// measurement. Updated every iteration (not just on a produced frame) so they stay current
|
// measurement. Updated every iteration (not just on a produced frame) so they stay current
|
||||||
@@ -1534,13 +1685,19 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
p.active && !p.done
|
p.active && !p.done
|
||||||
};
|
};
|
||||||
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
if !probe_active && last_report.elapsed() >= ADAPT_REPORT_INTERVAL {
|
||||||
|
// A no-op clock flush earlier in this window suspected a wall-clock step: fire
|
||||||
|
// the mid-stream re-sync now (once — the 60 s periodic covers everything else).
|
||||||
|
if resync_wanted {
|
||||||
|
resync_wanted = false;
|
||||||
|
let _ = ctrl_tx.try_send(CtrlRequest::ClockResync);
|
||||||
|
}
|
||||||
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
let window_dropped = st.frames_dropped.wrapping_sub(last_dropped);
|
||||||
let loss_ppm = window_loss_ppm(
|
let loss_ppm = window_loss_ppm(
|
||||||
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
st.fec_recovered_shards.wrapping_sub(last_recovered),
|
||||||
st.packets_received.wrapping_sub(last_received),
|
st.packets_received.wrapping_sub(last_received),
|
||||||
window_dropped,
|
window_dropped,
|
||||||
);
|
);
|
||||||
let _ = ctrl_tx.send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
let _ = ctrl_tx.try_send(CtrlRequest::Loss(LossReport { loss_ppm }));
|
||||||
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
// Adaptive bitrate: drain any host ack first (its clamp is authoritative), then
|
||||||
// feed the controller this window's congestion signals; a decision becomes a
|
// feed the controller this window's congestion signals; a decision becomes a
|
||||||
// SetBitrate on the control stream.
|
// SetBitrate on the control stream.
|
||||||
@@ -1558,7 +1715,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
flush_in_window,
|
flush_in_window,
|
||||||
) {
|
) {
|
||||||
tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target");
|
tracing::info!(kbps, "adaptive bitrate: requesting encoder re-target");
|
||||||
let _ = ctrl_tx.send(CtrlRequest::SetBitrate(kbps));
|
let _ = ctrl_tx.try_send(CtrlRequest::SetBitrate(kbps));
|
||||||
}
|
}
|
||||||
flush_in_window = false;
|
flush_in_window = false;
|
||||||
last_report = Instant::now();
|
last_report = Instant::now();
|
||||||
@@ -1605,7 +1762,10 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
owd_sum_ns += lat_ns;
|
owd_sum_ns += lat_ns;
|
||||||
owd_frames += 1;
|
owd_frames += 1;
|
||||||
}
|
}
|
||||||
if clock_offset_ns != 0 && lat_ns > FLUSH_LATENCY.as_nanos() as i128 {
|
if clock_detector_armed
|
||||||
|
&& clock_offset_ns != 0
|
||||||
|
&& lat_ns > FLUSH_LATENCY.as_nanos() as i128
|
||||||
|
{
|
||||||
stale_frames += 1;
|
stale_frames += 1;
|
||||||
} else {
|
} else {
|
||||||
stale_frames = 0;
|
stale_frames = 0;
|
||||||
@@ -1627,7 +1787,7 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
flush_in_window = true; // strongest "link can't hold the rate" signal
|
flush_in_window = true; // strongest "link can't hold the rate" signal
|
||||||
let flushed = session.flush_backlog().unwrap_or(0);
|
let flushed = session.flush_backlog().unwrap_or(0);
|
||||||
let dropped = frames.clear();
|
let dropped = frames.clear();
|
||||||
let _ = ctrl_tx.send(CtrlRequest::Keyframe);
|
let _ = ctrl_tx.try_send(CtrlRequest::Keyframe);
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
|
behind_ms = if clock_behind { lat_ns / 1_000_000 } else { -1 },
|
||||||
queue_depth = depth,
|
queue_depth = depth,
|
||||||
@@ -1635,6 +1795,34 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
dropped_frames = dropped,
|
dropped_frames = dropped,
|
||||||
"receive backlog stopped draining — jumped to live (flush + keyframe)"
|
"receive backlog stopped draining — jumped to live (flush + keyframe)"
|
||||||
);
|
);
|
||||||
|
// Clock-detector health check: a clock-only trigger whose flush found
|
||||||
|
// no local backlog is a false "behind" reading (a wall-clock step, or
|
||||||
|
// an upstream queue a local flush can't drain) — repeated, it would
|
||||||
|
// cost a recovery IDR every cooldown forever. Disarm after two in a
|
||||||
|
// row; the clock-free queue detector keeps covering real backlogs.
|
||||||
|
if clock_behind && !queue_behind
|
||||||
|
&& flushed < NOOP_FLUSH_DATAGRAMS
|
||||||
|
&& dropped == 0
|
||||||
|
{
|
||||||
|
noop_clock_flushes += 1;
|
||||||
|
if noop_clock_flushes == 1 {
|
||||||
|
// First no-op flush = a wall-clock step is the prime
|
||||||
|
// suspect: ask for an immediate re-sync (sent on the next
|
||||||
|
// report tick). Applied, it resets these counters and
|
||||||
|
// re-arms the detector before the disarm below triggers.
|
||||||
|
resync_wanted = true;
|
||||||
|
}
|
||||||
|
if noop_clock_flushes >= NOOP_CLOCK_FLUSHES_TO_DISARM {
|
||||||
|
clock_detector_armed = false;
|
||||||
|
tracing::warn!(
|
||||||
|
"clock-based jump-to-live disarmed — its flushes found no \
|
||||||
|
local backlog (clock step or upstream queueing suspected); \
|
||||||
|
the queue-depth detector stays armed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
noop_clock_flushes = 0;
|
||||||
|
}
|
||||||
continue; // this frame is part of the stale past — don't render it
|
continue; // this frame is part of the stale past — don't render it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,31 @@ impl SessionCrypto {
|
|||||||
)
|
)
|
||||||
.map_err(|_| PunktfunkError::Crypto)
|
.map_err(|_| PunktfunkError::Crypto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open in place, no per-packet allocation: `buf` holds `[ciphertext .. ][tag]` on entry and
|
||||||
|
/// the plaintext in its first `buf.len() - TAG_LEN` bytes on success (returned as the length)
|
||||||
|
/// — byte-identical to `open`, just written in place. GCM verifies the tag *before*
|
||||||
|
/// decrypting, so on failure `buf` still holds the ciphertext (the caller drops the packet
|
||||||
|
/// either way). The hot-path receiver (`Session::poll_frame`) uses this to avoid the `Vec`
|
||||||
|
/// that `open`'s convenience API allocates for every datagram at line rate — the receive
|
||||||
|
/// mirror of [`seal_in_place`](Self::seal_in_place).
|
||||||
|
pub fn open_in_place(&self, seq: u64, buf: &mut [u8]) -> Result<usize> {
|
||||||
|
if buf.len() < TAG_LEN {
|
||||||
|
return Err(PunktfunkError::BadPacket);
|
||||||
|
}
|
||||||
|
let nonce = nonce(self.recv_salt, seq);
|
||||||
|
let split = buf.len() - TAG_LEN;
|
||||||
|
let (ciphertext, tag) = buf.split_at_mut(split);
|
||||||
|
self.cipher
|
||||||
|
.decrypt_in_place_detached(
|
||||||
|
Nonce::from_slice(&nonce),
|
||||||
|
&seq.to_be_bytes(),
|
||||||
|
ciphertext,
|
||||||
|
aes_gcm::Tag::from_slice(tag),
|
||||||
|
)
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
|
Ok(split)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direction(role: Role) -> u8 {
|
fn direction(role: Role) -> u8 {
|
||||||
@@ -164,6 +189,39 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn open_in_place_matches_open_and_rejects_tampering() {
|
||||||
|
let key = random_key();
|
||||||
|
let salt = random_salt();
|
||||||
|
let host = SessionCrypto::new(&key, salt, Role::Host);
|
||||||
|
let client = SessionCrypto::new(&key, salt, Role::Client);
|
||||||
|
for msg in [
|
||||||
|
&b""[..],
|
||||||
|
b"x",
|
||||||
|
b"the quick brown fox jumps over 13 lazy dogs!!",
|
||||||
|
] {
|
||||||
|
let sealed = host.seal(9, msg).unwrap();
|
||||||
|
let mut buf = sealed.clone();
|
||||||
|
let n = client.open_in_place(9, &mut buf).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
&buf[..n],
|
||||||
|
msg,
|
||||||
|
"in-place open must be byte-identical to open"
|
||||||
|
);
|
||||||
|
// Wrong sequence (nonce + AAD) → authentication failure, like `open`.
|
||||||
|
let mut buf = sealed.clone();
|
||||||
|
assert!(client.open_in_place(8, &mut buf).is_err());
|
||||||
|
// A flipped ciphertext/tag bit → authentication failure.
|
||||||
|
let mut buf = sealed.clone();
|
||||||
|
let last = buf.len() - 1;
|
||||||
|
buf[last] ^= 1;
|
||||||
|
assert!(client.open_in_place(9, &mut buf).is_err());
|
||||||
|
}
|
||||||
|
// Shorter than a tag can't be a sealed packet at all.
|
||||||
|
let mut runt = vec![0u8; TAG_LEN - 1];
|
||||||
|
assert!(client.open_in_place(0, &mut runt).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn seal_in_place_matches_seal_and_opens() {
|
fn seal_in_place_matches_seal_and_opens() {
|
||||||
let key = random_key();
|
let key = random_key();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ impl ErasureCoder for Gf16Coder {
|
|||||||
FecScheme::Gf16
|
FecScheme::Gf16
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
||||||
if recovery_count == 0 {
|
if recovery_count == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ impl ErasureCoder for Gf8Coder {
|
|||||||
FecScheme::Gf8
|
FecScheme::Gf8
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError> {
|
||||||
if recovery_count == 0 {
|
if recovery_count == 0 {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
}
|
}
|
||||||
@@ -24,13 +24,12 @@ impl ErasureCoder for Gf8Coder {
|
|||||||
let shard_len = data[0].len();
|
let shard_len = data[0].len();
|
||||||
let rs = ReedSolomon::new(k, recovery_count)
|
let rs = ReedSolomon::new(k, recovery_count)
|
||||||
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
.map_err(|_| FecError::Config("invalid GF(2^8) shard counts"))?;
|
||||||
// fec-rs fills parity in place: shards = data || zeroed parity.
|
// `encode_sep` reads the data shards by reference and fills the parity in place —
|
||||||
let mut shards: Vec<Vec<u8>> = Vec::with_capacity(k + recovery_count);
|
// same Cauchy codec as `encode`, without copying the data into a shards scratch.
|
||||||
shards.extend_from_slice(data);
|
let mut parity: Vec<Vec<u8>> = (0..recovery_count).map(|_| vec![0u8; shard_len]).collect();
|
||||||
shards.resize_with(k + recovery_count, || vec![0u8; shard_len]);
|
rs.encode_sep(data, &mut parity)
|
||||||
rs.encode(&mut shards)
|
|
||||||
.map_err(|_| FecError::Backend("gf8 encode"))?;
|
.map_err(|_| FecError::Backend("gf8 encode"))?;
|
||||||
Ok(shards.split_off(k))
|
Ok(parity)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reconstruct(
|
fn reconstruct(
|
||||||
@@ -84,7 +83,7 @@ mod tests {
|
|||||||
fn nanors_exact_parity_vectors() {
|
fn nanors_exact_parity_vectors() {
|
||||||
let coder = Gf8Coder;
|
let coder = Gf8Coder;
|
||||||
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
|
// The definitive nanors vector (k=4, m=2): single-byte shards [10,20,30,40] → [136, 0].
|
||||||
let data = vec![vec![10u8], vec![20], vec![30], vec![40]];
|
let data: [&[u8]; 4] = [&[10u8], &[20], &[30], &[40]];
|
||||||
let parity = coder.encode(&data, 2).unwrap();
|
let parity = coder.encode(&data, 2).unwrap();
|
||||||
assert_eq!(parity, vec![vec![136u8], vec![0u8]]);
|
assert_eq!(parity, vec![vec![136u8], vec![0u8]]);
|
||||||
|
|
||||||
@@ -106,7 +105,8 @@ mod tests {
|
|||||||
fn recovers_erased_data_shards() {
|
fn recovers_erased_data_shards() {
|
||||||
let coder = Gf8Coder;
|
let coder = Gf8Coder;
|
||||||
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
|
let data: Vec<Vec<u8>> = (0..6).map(|i| vec![i as u8; 8]).collect();
|
||||||
let parity = coder.encode(&data, 3).unwrap();
|
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||||
|
let parity = coder.encode(&refs, 3).unwrap();
|
||||||
let mut received: Vec<Option<Vec<u8>>> = data
|
let mut received: Vec<Option<Vec<u8>>> = data
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ pub trait ErasureCoder: Send + Sync {
|
|||||||
|
|
||||||
/// Encode `data` (K original shards) into `recovery_count` (M) parity shards.
|
/// Encode `data` (K original shards) into `recovery_count` (M) parity shards.
|
||||||
/// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`.
|
/// Returns the M recovery shards. `recovery_count == 0` returns an empty `Vec`.
|
||||||
fn encode(&self, data: &[Vec<u8>], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
|
/// Takes shard *references* so the packetizer can point straight into the frame
|
||||||
|
/// buffer instead of copying every data byte into per-shard `Vec`s first.
|
||||||
|
fn encode(&self, data: &[&[u8]], recovery_count: usize) -> Result<Vec<Vec<u8>>, FecError>;
|
||||||
|
|
||||||
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
|
/// Reconstruct the K original shards. `received` has length K+M: indices `0..K` are
|
||||||
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
|
/// originals, `K..K+M` are recovery shards; `Some` = present, `None` = lost.
|
||||||
@@ -79,7 +81,7 @@ pub(crate) fn validate_block_shape(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate `encode` inputs: at least one data shard, all of equal length.
|
/// Validate `encode` inputs: at least one data shard, all of equal length.
|
||||||
pub(crate) fn validate_encode_shape(data: &[Vec<u8>]) -> Result<(), FecError> {
|
pub(crate) fn validate_encode_shape(data: &[&[u8]]) -> Result<(), FecError> {
|
||||||
let first = data
|
let first = data
|
||||||
.first()
|
.first()
|
||||||
.ok_or(FecError::Config("no data shards"))?
|
.ok_or(FecError::Config("no data shards"))?
|
||||||
@@ -100,7 +102,8 @@ mod tests {
|
|||||||
let data: Vec<Vec<u8>> = (0..k)
|
let data: Vec<Vec<u8>> = (0..k)
|
||||||
.map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect())
|
.map(|i| (0..shard_len).map(|b| (i * 31 + b * 7) as u8).collect())
|
||||||
.collect();
|
.collect();
|
||||||
let recovery = coder.encode(&data, m).unwrap();
|
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||||
|
let recovery = coder.encode(&refs, m).unwrap();
|
||||||
assert_eq!(recovery.len(), m);
|
assert_eq!(recovery.len(), m);
|
||||||
|
|
||||||
let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m);
|
let mut received: Vec<Option<Vec<u8>>> = Vec::with_capacity(k + m);
|
||||||
@@ -128,7 +131,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn gf8_too_much_loss_errors() {
|
fn gf8_too_much_loss_errors() {
|
||||||
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
|
let data: Vec<Vec<u8>> = (0..8).map(|_| vec![0u8; 64]).collect();
|
||||||
let recovery = Gf8Coder.encode(&data, 2).unwrap();
|
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||||
|
let recovery = Gf8Coder.encode(&refs, 2).unwrap();
|
||||||
let mut received: Vec<Option<Vec<u8>>> = data
|
let mut received: Vec<Option<Vec<u8>>> = data
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ pub struct Packetizer {
|
|||||||
shard_payload: usize,
|
shard_payload: usize,
|
||||||
fec: crate::config::FecConfig,
|
fec: crate::config::FecConfig,
|
||||||
version: u8,
|
version: u8,
|
||||||
|
/// Reusable zero-padded scratch for the frame's final data shard when the frame isn't an
|
||||||
|
/// exact `shard_payload` multiple (and for the single all-zero shard of an empty frame).
|
||||||
|
/// Every other data shard is a `shard_payload`-sized slice straight into the frame buffer —
|
||||||
|
/// blocks are consecutive shard ranges, so only the frame's last shard can be partial.
|
||||||
|
tail: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Packetizer {
|
impl Packetizer {
|
||||||
@@ -114,6 +119,7 @@ impl Packetizer {
|
|||||||
shard_payload: config.shard_payload,
|
shard_payload: config.shard_payload,
|
||||||
fec: config.fec,
|
fec: config.fec,
|
||||||
version: config.phase as u8,
|
version: config.phase as u8,
|
||||||
|
tail: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +135,9 @@ impl Packetizer {
|
|||||||
self.fec.fec_percent
|
self.fec.fec_percent
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Packetize one access unit into wire packets (header + shard payload each).
|
/// Packetize one access unit into owned wire packets (header ++ shard payload each).
|
||||||
|
/// Thin wrapper over [`packetize_each`](Self::packetize_each) — the allocation-free
|
||||||
|
/// streaming path's reference implementation (tests and the loss harness use this).
|
||||||
pub fn packetize(
|
pub fn packetize(
|
||||||
&mut self,
|
&mut self,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
@@ -137,6 +145,31 @@ impl Packetizer {
|
|||||||
user_flags: u32,
|
user_flags: u32,
|
||||||
coder: &dyn ErasureCoder,
|
coder: &dyn ErasureCoder,
|
||||||
) -> Result<Vec<Vec<u8>>> {
|
) -> Result<Vec<Vec<u8>>> {
|
||||||
|
let mut packets = Vec::new();
|
||||||
|
self.packetize_each(frame, pts_ns, user_flags, coder, |hdr, body| {
|
||||||
|
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
|
||||||
|
pkt.extend_from_slice(hdr.as_bytes());
|
||||||
|
pkt.extend_from_slice(body);
|
||||||
|
packets.push(pkt);
|
||||||
|
Ok(())
|
||||||
|
})?;
|
||||||
|
Ok(packets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Packetize one access unit, yielding each packet to `emit` as a `(header, shard bytes)`
|
||||||
|
/// pair — in exact wire order, which is also the order the session's nonce counter
|
||||||
|
/// advances. No per-packet allocation happens here, so the caller can write header and
|
||||||
|
/// shard straight into a pooled wire buffer and seal in place
|
||||||
|
/// ([`Session::seal_frame`](crate::session::Session::seal_frame)). An `emit` error aborts
|
||||||
|
/// the frame mid-way (packet numbering has already advanced — callers treat it as fatal).
|
||||||
|
pub fn packetize_each(
|
||||||
|
&mut self,
|
||||||
|
frame: &[u8],
|
||||||
|
pts_ns: u64,
|
||||||
|
user_flags: u32,
|
||||||
|
coder: &dyn ErasureCoder,
|
||||||
|
mut emit: impl FnMut(&PacketHeader, &[u8]) -> Result<()>,
|
||||||
|
) -> Result<()> {
|
||||||
let payload = self.shard_payload;
|
let payload = self.shard_payload;
|
||||||
let frame_index = self.next_frame_index;
|
let frame_index = self.next_frame_index;
|
||||||
self.next_frame_index = self.next_frame_index.wrapping_add(1);
|
self.next_frame_index = self.next_frame_index.wrapping_add(1);
|
||||||
@@ -159,23 +192,31 @@ impl Packetizer {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut packets = Vec::new();
|
// Stage the frame's one possibly-partial shard (the last) in the reusable
|
||||||
|
// zero-padded scratch; every full shard is referenced in place below.
|
||||||
|
let full_shards = frame.len() / payload;
|
||||||
|
self.tail.clear();
|
||||||
|
self.tail.resize(payload, 0);
|
||||||
|
let rem = frame.len() % payload;
|
||||||
|
if rem > 0 {
|
||||||
|
self.tail[..rem].copy_from_slice(&frame[full_shards * payload..]);
|
||||||
|
}
|
||||||
|
let tail = &self.tail;
|
||||||
|
let shard_at = |s: usize| -> &[u8] {
|
||||||
|
if s < full_shards {
|
||||||
|
&frame[s * payload..(s + 1) * payload]
|
||||||
|
} else {
|
||||||
|
tail.as_slice()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
for b in 0..block_count {
|
for b in 0..block_count {
|
||||||
let first = b * max_block;
|
let first = b * max_block;
|
||||||
let last = ((b + 1) * max_block).min(total_data);
|
let last = ((b + 1) * max_block).min(total_data);
|
||||||
let block_data_count = last - first;
|
let block_data_count = last - first;
|
||||||
|
|
||||||
// Build this block's data shards (each `payload` bytes, last zero-padded).
|
// This block's data shards: references into `frame` (plus the staged tail).
|
||||||
let mut data_shards: Vec<Vec<u8>> = Vec::with_capacity(block_data_count);
|
let data_shards: Vec<&[u8]> = (first..last).map(shard_at).collect();
|
||||||
for s in first..last {
|
|
||||||
let start = s * payload;
|
|
||||||
let end = (start + payload).min(frame.len());
|
|
||||||
let mut shard = vec![0u8; payload];
|
|
||||||
if start < frame.len() {
|
|
||||||
shard[..end - start].copy_from_slice(&frame[start..end]);
|
|
||||||
}
|
|
||||||
data_shards.push(shard);
|
|
||||||
}
|
|
||||||
|
|
||||||
let recovery_count = self.fec.recovery_for(block_data_count);
|
let recovery_count = self.fec.recovery_for(block_data_count);
|
||||||
let recovery = coder.encode(&data_shards, recovery_count)?;
|
let recovery = coder.encode(&data_shards, recovery_count)?;
|
||||||
@@ -186,7 +227,7 @@ impl Packetizer {
|
|||||||
|
|
||||||
for shard_index in 0..total_shards {
|
for shard_index in 0..total_shards {
|
||||||
let body: &[u8] = if shard_index < block_data_count {
|
let body: &[u8] = if shard_index < block_data_count {
|
||||||
&data_shards[shard_index]
|
data_shards[shard_index]
|
||||||
} else {
|
} else {
|
||||||
&recovery[shard_index - block_data_count]
|
&recovery[shard_index - block_data_count]
|
||||||
};
|
};
|
||||||
@@ -219,14 +260,10 @@ impl Packetizer {
|
|||||||
fec_scheme: coder.scheme() as u8,
|
fec_scheme: coder.scheme() as u8,
|
||||||
flags,
|
flags,
|
||||||
};
|
};
|
||||||
|
emit(&hdr, body)?;
|
||||||
let mut pkt = Vec::with_capacity(HEADER_LEN + body.len());
|
|
||||||
pkt.extend_from_slice(hdr.as_bytes());
|
|
||||||
pkt.extend_from_slice(body);
|
|
||||||
packets.push(pkt);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(packets)
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
|||||||
|
//! Wall-clock skew: the connect-time handshake ([`clock_sync`]), the NTP-style offset
|
||||||
|
//! estimator ([`clock_offset_ns`]), and the mid-stream re-sync state machine
|
||||||
|
//! ([`ClockResync`]).
|
||||||
|
|
||||||
|
use super::{io, ClockEcho, ClockProbe};
|
||||||
|
|
||||||
|
/// Estimate the host↔client clock offset (**host minus client**, ns) and RTT (ns) from skew-handshake
|
||||||
|
/// samples `(t1, t2, t3, t4)` — NTP's formula, taking the **minimum-RTT** sample (least queuing
|
||||||
|
/// noise; also discards the first round's host-setup latency). Offset is positive when the host
|
||||||
|
/// clock is ahead of the client's; add it to a client timestamp to express it in the host clock.
|
||||||
|
/// Returns `None` for an empty sample set.
|
||||||
|
pub fn clock_offset_ns(samples: &[(u64, u64, u64, u64)]) -> Option<(i64, u64)> {
|
||||||
|
samples
|
||||||
|
.iter()
|
||||||
|
.map(|&(t1, t2, t3, t4)| {
|
||||||
|
let rtt = ((t4 as i128 - t1 as i128) - (t3 as i128 - t2 as i128)).max(0) as u64;
|
||||||
|
let offset = (((t2 as i128 - t1 as i128) + (t3 as i128 - t4 as i128)) / 2) as i64;
|
||||||
|
(offset, rtt)
|
||||||
|
})
|
||||||
|
.min_by_key(|&(_, rtt)| rtt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One wall-clock skew-handshake outcome (see [`clock_sync`]).
|
||||||
|
pub struct ClockSkew {
|
||||||
|
/// Host clock minus client clock, ns: add it to a client timestamp to express it in host time.
|
||||||
|
pub offset_ns: i64,
|
||||||
|
/// Round-trip time of the minimum-RTT sample, ns.
|
||||||
|
pub rtt_ns: u64,
|
||||||
|
/// How many probe rounds the host answered.
|
||||||
|
pub rounds: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the wall-clock skew handshake from the client side over the (already-open) control stream:
|
||||||
|
/// `ROUNDS` [`ClockProbe`]/[`ClockEcho`] round-trips, returning the host↔client offset from the
|
||||||
|
/// minimum-RTT sample. `None` if the host never answers (an old host) — the caller then assumes a
|
||||||
|
/// shared clock. Each read is bounded so a silent host can't wedge session start. Shared by the
|
||||||
|
/// reference client and the embeddable connector; uses the realtime clock the host stamps `pts_ns`
|
||||||
|
/// with, so the offset aligns a client receive instant to the host's capture clock.
|
||||||
|
pub async fn clock_sync(
|
||||||
|
send: &mut quinn::SendStream,
|
||||||
|
recv: &mut quinn::RecvStream,
|
||||||
|
) -> Option<ClockSkew> {
|
||||||
|
use std::time::Duration;
|
||||||
|
const ROUNDS: usize = 8;
|
||||||
|
let read_timeout = Duration::from_secs(2);
|
||||||
|
let mut samples: Vec<(u64, u64, u64, u64)> = Vec::with_capacity(ROUNDS);
|
||||||
|
for _ in 0..ROUNDS {
|
||||||
|
let t1 = wall_clock_ns();
|
||||||
|
let probe = ClockProbe { t1_ns: t1 }.encode();
|
||||||
|
if io::write_msg(send, &probe).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let read = tokio::time::timeout(read_timeout, io::read_msg(recv)).await;
|
||||||
|
let echo = match read {
|
||||||
|
Ok(Ok(b)) => match ClockEcho::decode(&b) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
_ => break, // timeout or stream error -> old host / no skew support
|
||||||
|
};
|
||||||
|
samples.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, wall_clock_ns()));
|
||||||
|
}
|
||||||
|
clock_offset_ns(&samples).map(|(offset_ns, rtt_ns)| ClockSkew {
|
||||||
|
offset_ns,
|
||||||
|
rtt_ns,
|
||||||
|
rounds: samples.len(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall-clock now (ns since the Unix epoch) — the clock the skew handshake stamps and the host
|
||||||
|
/// stamps AU `pts_ns` with (CLOCK_REALTIME basis, deliberately NOT monotonic: steps/slew are
|
||||||
|
/// exactly what the handshake measures across machines).
|
||||||
|
pub fn wall_clock_ns() -> u64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`ClockResync::on_echo`] asks the driver to do next.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum ResyncStep {
|
||||||
|
/// Nothing — the echo was stale (a previous batch) or no batch is in flight.
|
||||||
|
Idle,
|
||||||
|
/// Send this next-round probe and keep feeding echoes.
|
||||||
|
Probe(ClockProbe),
|
||||||
|
/// The batch is complete: the min-RTT estimate over its rounds, per [`clock_offset_ns`].
|
||||||
|
Done { offset_ns: i64, rtt_ns: u64 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mid-stream wall-clock re-sync (networking-audit deferred plan §2): the same 8-round
|
||||||
|
/// probe/echo estimate as the connect-time [`clock_sync`], restructured as a state machine so
|
||||||
|
/// the client's control task can drive it from its `select!` loop without blocking the stream —
|
||||||
|
/// echoes interleave with other control traffic; rounds are matched by the echoed `t1`.
|
||||||
|
///
|
||||||
|
/// A step or slow drift of either wall clock after connect silently corrupts the clock-based
|
||||||
|
/// jump-to-live signal, the ABR one-way-delay signal, and every latency stat. Re-syncing
|
||||||
|
/// restores them; the disarm heuristic stays as the final backstop.
|
||||||
|
pub struct ClockResync {
|
||||||
|
/// `t1_ns` of the probe in flight; `None` = no batch active. An echo whose `t1` doesn't
|
||||||
|
/// match is stale (an abandoned batch) and ignored.
|
||||||
|
pending_t1: Option<u64>,
|
||||||
|
samples: Vec<(u64, u64, u64, u64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ClockResync {
|
||||||
|
/// Rounds per batch — matches the connect-time [`clock_sync`].
|
||||||
|
pub const ROUNDS: usize = 8;
|
||||||
|
|
||||||
|
pub fn new() -> ClockResync {
|
||||||
|
ClockResync {
|
||||||
|
pending_t1: None,
|
||||||
|
samples: Vec::with_capacity(Self::ROUNDS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a (new) batch, abandoning any batch still in flight — its late echoes won't match
|
||||||
|
/// `pending_t1` and get ignored. Returns the first probe to send, stamped `now_ns`.
|
||||||
|
pub fn begin(&mut self, now_ns: u64) -> ClockProbe {
|
||||||
|
self.samples.clear();
|
||||||
|
self.pending_t1 = Some(now_ns);
|
||||||
|
ClockProbe { t1_ns: now_ns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed an inbound [`ClockEcho`] received at `now_ns` (the round's `t4`).
|
||||||
|
pub fn on_echo(&mut self, echo: &ClockEcho, now_ns: u64) -> ResyncStep {
|
||||||
|
if self.pending_t1 != Some(echo.t1_ns) {
|
||||||
|
return ResyncStep::Idle; // stale (abandoned batch) or unsolicited
|
||||||
|
}
|
||||||
|
self.samples
|
||||||
|
.push((echo.t1_ns, echo.t2_ns, echo.t3_ns, now_ns));
|
||||||
|
if self.samples.len() < Self::ROUNDS {
|
||||||
|
self.pending_t1 = Some(now_ns);
|
||||||
|
return ResyncStep::Probe(ClockProbe { t1_ns: now_ns });
|
||||||
|
}
|
||||||
|
self.pending_t1 = None;
|
||||||
|
match clock_offset_ns(&self.samples) {
|
||||||
|
Some((offset_ns, rtt_ns)) => ResyncStep::Done { offset_ns, rtt_ns },
|
||||||
|
None => ResyncStep::Idle, // unreachable: ROUNDS > 0 samples were just collected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ClockResync {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acceptance guard for a re-sync batch: apply the new offset only when its min RTT is
|
||||||
|
/// comparable to the connect-time RTT — `≤ max(2 ms, 1.5 × connect RTT)`. A congested window
|
||||||
|
/// biases the offset by its queueing delay, and frames already read late exactly then; better
|
||||||
|
/// to keep the old estimate and let the next batch try again.
|
||||||
|
pub fn accept_resync(batch_rtt_ns: u64, connect_rtt_ns: u64) -> bool {
|
||||||
|
batch_rtt_ns <= (connect_rtt_ns + connect_rtt_ns / 2).max(2_000_000)
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! The QUIC-datagram side planes, demultiplexed by their first byte (0xC9–0xCF):
|
||||||
|
//! audio, rumble, mic uplink, rich input, HID output, HDR metadata, host timing.
|
||||||
|
|
||||||
|
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||||
|
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8, client→host),
|
||||||
|
/// audio = [`AUDIO_MAGIC`] (0xC9, host→client), rumble = [`RUMBLE_MAGIC`] (0xCA, host→client),
|
||||||
|
/// mic = [`MIC_MAGIC`] (0xCB, client→host), rich-input = [`RICH_INPUT_MAGIC`] (0xCC, client→host),
|
||||||
|
/// HID-output = [`HIDOUT_MAGIC`] (0xCD, host→client), HDR metadata = [`HDR_META_MAGIC`]
|
||||||
|
/// (0xCE, host→client).
|
||||||
|
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||||
|
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||||
|
/// 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.
|
||||||
|
pub const MIC_MAGIC: u8 = 0xCB;
|
||||||
|
/// 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`]).
|
||||||
|
pub const RICH_INPUT_MAGIC: u8 = 0xCC;
|
||||||
|
/// 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`].
|
||||||
|
pub const HIDOUT_MAGIC: u8 = 0xCD;
|
||||||
|
|
||||||
|
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
|
||||||
|
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
|
||||||
|
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(13 + opus.len());
|
||||||
|
b.push(AUDIO_MAGIC);
|
||||||
|
b.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(opus);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||||
|
if b.len() < 13 || b[0] != AUDIO_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());
|
||||||
|
Some((seq, pts_ns, &b[13..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
|
||||||
|
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
|
||||||
|
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
|
||||||
|
let mut b = [0u8; 7];
|
||||||
|
b[0] = RUMBLE_MAGIC;
|
||||||
|
b[1..3].copy_from_slice(&pad.to_le_bytes());
|
||||||
|
b[3..5].copy_from_slice(&low.to_le_bytes());
|
||||||
|
b[5..7].copy_from_slice(&high.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
|
||||||
|
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
Some((u16at(1), u16at(3), u16at(5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mic datagram, client → host: `[0xCB][u32 seq LE][u64 pts_ns LE][opus payload]` — the same
|
||||||
|
/// layout as [`encode_audio_datagram`] with [`MIC_MAGIC`], one Opus frame per datagram.
|
||||||
|
pub fn encode_mic_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(13 + opus.len());
|
||||||
|
b.push(MIC_MAGIC);
|
||||||
|
b.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(opus);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a mic datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
|
||||||
|
pub fn decode_mic_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||||
|
if b.len() < 13 || b[0] != MIC_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());
|
||||||
|
Some((seq, pts_ns, &b[13..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) const RICH_TOUCHPAD: u8 = 0x01;
|
||||||
|
pub(super) const RICH_MOTION: u8 = 0x02;
|
||||||
|
pub(super) const RICH_TOUCHPAD_EX: u8 = 0x03;
|
||||||
|
|
||||||
|
/// A rich client→host controller input beyond the fixed [`InputEvent`](crate::input::InputEvent):
|
||||||
|
/// the DualSense touchpad and motion sensors. `pad` is the gamepad index. Wire form is
|
||||||
|
/// `[0xCC][kind][fields…]` — variable-length and kind-tagged (forward-compatible: an unknown
|
||||||
|
/// kind decodes to `None` and is dropped).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum RichInput {
|
||||||
|
/// One touchpad contact. `x`/`y` are normalized `0..=65535` in SCREEN convention —
|
||||||
|
/// origin top-left, +y DOWN, exactly what SDL/Windows/Android capture APIs produce
|
||||||
|
/// (the host scales to the DualSense touchpad resolution); `active = false` lifts
|
||||||
|
/// the finger.
|
||||||
|
Touchpad {
|
||||||
|
pad: u8,
|
||||||
|
finger: u8,
|
||||||
|
active: bool,
|
||||||
|
x: u16,
|
||||||
|
y: u16,
|
||||||
|
},
|
||||||
|
/// Motion sensors: `gyro` (pitch/yaw/roll) + `accel`, raw signed-16 in the sensor's own
|
||||||
|
/// units — passed straight into the DualSense report.
|
||||||
|
Motion {
|
||||||
|
pad: u8,
|
||||||
|
gyro: [i16; 3],
|
||||||
|
accel: [i16; 3],
|
||||||
|
},
|
||||||
|
/// A richer trackpad contact that also identifies *which* physical pad (Steam Controller / Deck
|
||||||
|
/// have two), carries a separate click vs touch state, and a pressure reading. `surface`:
|
||||||
|
/// `0` = the single / DualSense touchpad, `1` = the Steam left pad, `2` = the Steam right pad.
|
||||||
|
/// Coordinates are **signed** (centred at 0) in SCREEN convention — +x right, +y DOWN,
|
||||||
|
/// what every client capture API produces. Device-raw quirks are the HOST applier's job
|
||||||
|
/// (the Deck report is +y up: `steam_proto` flips it — the first live session shipped
|
||||||
|
/// clients that sent screen-y straight through, so the wire meaning is fixed as screen-y
|
||||||
|
/// and hosts translate). `pressure` is `0` for a surface with no force sensor. New clients
|
||||||
|
/// send this for every touch surface; the host decodes both `Touchpad` (`0x01`) and
|
||||||
|
/// `TouchpadEx` (`0x03`) indefinitely.
|
||||||
|
TouchpadEx {
|
||||||
|
pad: u8,
|
||||||
|
surface: u8,
|
||||||
|
finger: u8,
|
||||||
|
touch: bool,
|
||||||
|
click: bool,
|
||||||
|
x: i16,
|
||||||
|
y: i16,
|
||||||
|
pressure: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RichInput {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut out = vec![RICH_INPUT_MAGIC];
|
||||||
|
match *self {
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad,
|
||||||
|
finger,
|
||||||
|
active,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
} => {
|
||||||
|
out.extend_from_slice(&[RICH_TOUCHPAD, pad, finger, active as u8]);
|
||||||
|
out.extend_from_slice(&x.to_le_bytes());
|
||||||
|
out.extend_from_slice(&y.to_le_bytes());
|
||||||
|
}
|
||||||
|
RichInput::Motion { pad, gyro, accel } => {
|
||||||
|
out.extend_from_slice(&[RICH_MOTION, pad]);
|
||||||
|
for v in gyro.iter().chain(accel.iter()) {
|
||||||
|
out.extend_from_slice(&v.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad,
|
||||||
|
surface,
|
||||||
|
finger,
|
||||||
|
touch,
|
||||||
|
click,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
pressure,
|
||||||
|
} => {
|
||||||
|
let state = (touch as u8) | ((click as u8) << 1);
|
||||||
|
out.extend_from_slice(&[RICH_TOUCHPAD_EX, pad, surface, finger, state]);
|
||||||
|
out.extend_from_slice(&x.to_le_bytes());
|
||||||
|
out.extend_from_slice(&y.to_le_bytes());
|
||||||
|
out.extend_from_slice(&pressure.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Option<RichInput> {
|
||||||
|
if b.first() != Some(&RICH_INPUT_MAGIC) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match *b.get(1)? {
|
||||||
|
RICH_TOUCHPAD if b.len() >= 9 => Some(RichInput::Touchpad {
|
||||||
|
pad: b[2],
|
||||||
|
finger: b[3],
|
||||||
|
active: b[4] != 0,
|
||||||
|
x: u16::from_le_bytes([b[5], b[6]]),
|
||||||
|
y: u16::from_le_bytes([b[7], b[8]]),
|
||||||
|
}),
|
||||||
|
RICH_MOTION if b.len() >= 15 => {
|
||||||
|
let i16at = |o: usize| i16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
Some(RichInput::Motion {
|
||||||
|
pad: b[2],
|
||||||
|
gyro: [i16at(3), i16at(5), i16at(7)],
|
||||||
|
accel: [i16at(9), i16at(11), i16at(13)],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
RICH_TOUCHPAD_EX if b.len() >= 12 => Some(RichInput::TouchpadEx {
|
||||||
|
pad: b[2],
|
||||||
|
surface: b[3],
|
||||||
|
finger: b[4],
|
||||||
|
touch: b[5] & 0x01 != 0,
|
||||||
|
click: b[5] & 0x02 != 0,
|
||||||
|
x: i16::from_le_bytes([b[6], b[7]]),
|
||||||
|
y: i16::from_le_bytes([b[8], b[9]]),
|
||||||
|
pressure: u16::from_le_bytes([b[10], b[11]]),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const HIDOUT_LED: u8 = 0x01;
|
||||||
|
const HIDOUT_PLAYER_LEDS: u8 = 0x02;
|
||||||
|
const HIDOUT_TRIGGER: u8 = 0x03;
|
||||||
|
const HIDOUT_TRACKPAD_HAPTIC: u8 = 0x04;
|
||||||
|
|
||||||
|
/// DualSense feedback flowing host → client (what a game wrote to the host's virtual pad).
|
||||||
|
/// Wire form `[0xCD][kind][pad][fields…]`. The rich analog of the fixed rumble datagram;
|
||||||
|
/// rumble itself stays on [`RUMBLE_MAGIC`].
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum HidOutput {
|
||||||
|
/// Lightbar RGB.
|
||||||
|
Led { pad: u8, r: u8, g: u8, b: u8 },
|
||||||
|
/// Player-indicator LEDs (low 5 bits).
|
||||||
|
PlayerLeds { pad: u8, bits: u8 },
|
||||||
|
/// One adaptive-trigger effect: `which` 0 = L2, 1 = R2; `effect` is the raw DualSense
|
||||||
|
/// trigger parameter block (mode + params) for the client to replay on a real controller.
|
||||||
|
Trigger { pad: u8, which: u8, effect: Vec<u8> },
|
||||||
|
/// 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).
|
||||||
|
TrackpadHaptic {
|
||||||
|
pad: u8,
|
||||||
|
side: u8,
|
||||||
|
amplitude: u16,
|
||||||
|
period: u16,
|
||||||
|
count: u16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HidOutput {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut out = vec![HIDOUT_MAGIC];
|
||||||
|
match self {
|
||||||
|
HidOutput::Led { pad, r, g, b } => {
|
||||||
|
out.extend_from_slice(&[HIDOUT_LED, *pad, *r, *g, *b])
|
||||||
|
}
|
||||||
|
HidOutput::PlayerLeds { pad, bits } => {
|
||||||
|
out.extend_from_slice(&[HIDOUT_PLAYER_LEDS, *pad, *bits])
|
||||||
|
}
|
||||||
|
HidOutput::Trigger { pad, which, effect } => {
|
||||||
|
out.extend_from_slice(&[HIDOUT_TRIGGER, *pad, *which]);
|
||||||
|
out.extend_from_slice(effect);
|
||||||
|
}
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad,
|
||||||
|
side,
|
||||||
|
amplitude,
|
||||||
|
period,
|
||||||
|
count,
|
||||||
|
} => {
|
||||||
|
out.extend_from_slice(&[HIDOUT_TRACKPAD_HAPTIC, *pad, *side]);
|
||||||
|
out.extend_from_slice(&litude.to_le_bytes());
|
||||||
|
out.extend_from_slice(&period.to_le_bytes());
|
||||||
|
out.extend_from_slice(&count.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Option<HidOutput> {
|
||||||
|
if b.first() != Some(&HIDOUT_MAGIC) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
match *b.get(1)? {
|
||||||
|
HIDOUT_LED if b.len() >= 6 => Some(HidOutput::Led {
|
||||||
|
pad: b[2],
|
||||||
|
r: b[3],
|
||||||
|
g: b[4],
|
||||||
|
b: b[5],
|
||||||
|
}),
|
||||||
|
HIDOUT_PLAYER_LEDS if b.len() >= 4 => Some(HidOutput::PlayerLeds {
|
||||||
|
pad: b[2],
|
||||||
|
bits: b[3],
|
||||||
|
}),
|
||||||
|
HIDOUT_TRIGGER if b.len() >= 4 => Some(HidOutput::Trigger {
|
||||||
|
pad: b[2],
|
||||||
|
which: b[3],
|
||||||
|
effect: b[4..].to_vec(),
|
||||||
|
}),
|
||||||
|
HIDOUT_TRACKPAD_HAPTIC if b.len() >= 10 => Some(HidOutput::TrackpadHaptic {
|
||||||
|
pad: b[2],
|
||||||
|
side: b[3],
|
||||||
|
amplitude: u16::from_le_bytes([b[4], b[5]]),
|
||||||
|
period: u16::from_le_bytes([b[6], b[7]]),
|
||||||
|
count: u16::from_le_bytes([b[8], b[9]]),
|
||||||
|
}),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static HDR metadata, host → client: SMPTE ST.2086 mastering display colour volume + CEA-861.3
|
||||||
|
/// content light level. Tag [`HDR_META_MAGIC`]. Carried on a datagram (not [`Welcome`]) because it
|
||||||
|
/// is larger and can change mid-stream when the source's mastering intent changes; the host
|
||||||
|
/// re-sends it on keyframes so a client that dropped the best-effort datagram converges. Omitted
|
||||||
|
/// for HLG (scene-referred — no mastering metadata).
|
||||||
|
///
|
||||||
|
/// All fields use the standard HDR10 SEI fixed-point units, so they pass straight to
|
||||||
|
/// `DXGI_HDR_METADATA_HDR10` / Android `KEY_HDR_STATIC_INFO` / Apple `CAEDRMetadata` — the
|
||||||
|
/// libavcodec `AVMasteringDisplayMetadata` side needs an `AVRational` conversion.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub struct HdrMeta {
|
||||||
|
/// Display primaries G, B, R as (x, y) chromaticity in 1/50000 units (the ST.2086 RGB order
|
||||||
|
/// is G, B, R).
|
||||||
|
pub display_primaries: [[u16; 2]; 3],
|
||||||
|
/// White point (x, y) in 1/50000 units.
|
||||||
|
pub white_point: [u16; 2],
|
||||||
|
/// Max display mastering luminance, 0.0001 cd/m² units.
|
||||||
|
pub max_display_mastering_luminance: u32,
|
||||||
|
/// Min display mastering luminance, 0.0001 cd/m² units.
|
||||||
|
pub min_display_mastering_luminance: u32,
|
||||||
|
/// Maximum content light level (MaxCLL), nits. `0` = unknown.
|
||||||
|
pub max_cll: u16,
|
||||||
|
/// Maximum frame-average light level (MaxFALL), nits. `0` = unknown.
|
||||||
|
pub max_fall: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HDR static-metadata datagram tag, host → client (the static analog of the per-frame VUI;
|
||||||
|
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
|
||||||
|
pub const HDR_META_MAGIC: u8 = 0xCE;
|
||||||
|
|
||||||
|
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32
|
||||||
|
/// luminance + 2×u16 CLL/FALL = 29 bytes.
|
||||||
|
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4;
|
||||||
|
|
||||||
|
/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram.
|
||||||
|
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(HDR_META_LEN);
|
||||||
|
b.push(HDR_META_MAGIC);
|
||||||
|
for p in m.display_primaries.iter() {
|
||||||
|
b.extend_from_slice(&p[0].to_le_bytes());
|
||||||
|
b.extend_from_slice(&p[1].to_le_bytes());
|
||||||
|
}
|
||||||
|
b.extend_from_slice(&m.white_point[0].to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.white_point[1].to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_display_mastering_luminance.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_cll.to_le_bytes());
|
||||||
|
b.extend_from_slice(&m.max_fall.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a [`HDR_META_MAGIC`] datagram → [`HdrMeta`]. `None` on bad tag or a short/truncated buffer
|
||||||
|
/// (every attacker-controlled field is bounds-checked by the fixed length before any read).
|
||||||
|
pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
|
||||||
|
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||||
|
let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
|
||||||
|
Some(HdrMeta {
|
||||||
|
display_primaries: [
|
||||||
|
[u16at(1), u16at(3)],
|
||||||
|
[u16at(5), u16at(7)],
|
||||||
|
[u16at(9), u16at(11)],
|
||||||
|
],
|
||||||
|
white_point: [u16at(13), u16at(15)],
|
||||||
|
max_display_mastering_luminance: u32at(17),
|
||||||
|
min_display_mastering_luminance: u32at(21),
|
||||||
|
max_cll: u16at(25),
|
||||||
|
max_fall: u16at(27),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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`].
|
||||||
|
pub const HOST_TIMING_MAGIC: u8 = 0xCF;
|
||||||
|
|
||||||
|
/// One access unit's host-side processing time: capture → fully sent (the whole host pipeline —
|
||||||
|
/// capture read/convert, encode, FEC+seal, paced send). The client correlates it to the AU by
|
||||||
|
/// `pts_ns` (the AU's capture stamp, unique per frame) and derives
|
||||||
|
/// `network = (received + clock_offset − pts_ns) − host_us`, so the unified-stats equation's
|
||||||
|
/// `host+network` stage splits into two per-frame-tiling terms. Best-effort like every side-plane
|
||||||
|
/// datagram: a lost 0xCF just means that frame contributes no host/network sample.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct HostTiming {
|
||||||
|
/// The AU's capture stamp (host capture clock — matches the AU's `pts_ns` exactly).
|
||||||
|
pub pts_ns: u64,
|
||||||
|
/// Host capture→sent duration, µs (saturated at `u32::MAX` ≈ 71 min — far past the 10 s
|
||||||
|
/// client-side sanity clamp anyway).
|
||||||
|
pub host_us: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire length of a [`HOST_TIMING_MAGIC`] datagram: tag + u64 pts + u32 µs = 13 bytes.
|
||||||
|
const HOST_TIMING_LEN: usize = 1 + 8 + 4;
|
||||||
|
|
||||||
|
/// Encode a [`HostTiming`] into a [`HOST_TIMING_MAGIC`] datagram.
|
||||||
|
pub fn encode_host_timing_datagram(t: &HostTiming) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(HOST_TIMING_LEN);
|
||||||
|
b.push(HOST_TIMING_MAGIC);
|
||||||
|
b.extend_from_slice(&t.pts_ns.to_le_bytes());
|
||||||
|
b.extend_from_slice(&t.host_us.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a [`HOST_TIMING_MAGIC`] datagram → [`HostTiming`]. `None` on bad tag or a short buffer
|
||||||
|
/// (the fixed length bounds every read before it happens).
|
||||||
|
pub fn decode_host_timing_datagram(b: &[u8]) -> Option<HostTiming> {
|
||||||
|
if b.len() < HOST_TIMING_LEN || b[0] != HOST_TIMING_MAGIC {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(HostTiming {
|
||||||
|
pts_ns: u64::from_le_bytes(b[1..9].try_into().unwrap()),
|
||||||
|
host_us: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// Shared QUIC transport tuning for BOTH the host and client endpoints. Keep-alive is the
|
||||||
|
/// load-bearing setting: with quinn's defaults it is OFF, so any quiet stretch on the
|
||||||
|
/// connection (no input, audio muted or stalled, a capture hiccup, a mode change) lets the
|
||||||
|
/// idle timer run out and quinn closes the session — surfacing to the embedder as
|
||||||
|
/// `next_au` → Closed. The native equivalent of Moonlight's ENet keepalive: a small PING
|
||||||
|
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
|
||||||
|
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
|
||||||
|
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
|
||||||
|
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
|
||||||
|
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
|
||||||
|
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
|
||||||
|
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
|
||||||
|
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||||
|
|
||||||
|
fn stream_transport() -> Arc<quinn::TransportConfig> {
|
||||||
|
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
|
||||||
|
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
|
||||||
|
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
|
||||||
|
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
|
||||||
|
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
|
||||||
|
/// the keep-alive holds it open through quiet control periods.
|
||||||
|
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
|
||||||
|
use std::time::Duration;
|
||||||
|
let idle = idle.max(Duration::from_secs(1));
|
||||||
|
let keep_alive = (idle / 2).min(Duration::from_secs(4));
|
||||||
|
let mut t = quinn::TransportConfig::default();
|
||||||
|
t.max_idle_timeout(Some(
|
||||||
|
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
|
||||||
|
));
|
||||||
|
t.keep_alive_interval(Some(keep_alive));
|
||||||
|
// The datagram planes (audio/rumble/hidout/host-timing host→client; mic/rich-input
|
||||||
|
// client→host) carry realtime state, not bulk data — but they are congestion-controlled,
|
||||||
|
// unlike video, which rides its own latest-wins UDP path. quinn's default 1 MiB datagram
|
||||||
|
// send buffer is a FIFO that only sheds oldest-first at the cap, so on a congested link
|
||||||
|
// (Wi-Fi under streaming load) it holds tens of seconds of Opus: audio and rumble build a
|
||||||
|
// standing delay that never drains while video stays live. Capping the buffer makes the
|
||||||
|
// plane latest-wins at the source — ~200 ms of stereo Opus (proportionally less at
|
||||||
|
// surround bitrates), so sustained congestion costs concealable drops, never lag.
|
||||||
|
t.datagram_send_buffer_size(4 * 1024);
|
||||||
|
Arc::new(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
||||||
|
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
||||||
|
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk".into()])
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
||||||
|
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
|
||||||
|
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
|
||||||
|
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
|
||||||
|
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
|
||||||
|
pub fn server_with_identity(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
|
||||||
|
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
|
||||||
|
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
|
||||||
|
/// keep-alive scales with it so a live session never false-closes.
|
||||||
|
pub fn server_with_identity_idle(
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
cert_pem: &str,
|
||||||
|
key_pem: &str,
|
||||||
|
idle: std::time::Duration,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||||
|
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
|
||||||
|
server_from_der(cert_der, key_der, addr, idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
|
||||||
|
/// TLS layer (defense-in-depth) and makes the wire protocol explicit. Both ends set the SAME value;
|
||||||
|
/// a host with ALPN configured rejects a client that offers none, so client + host must be updated
|
||||||
|
/// together (acceptable while the protocol/ABI is still evolving).
|
||||||
|
const QUIC_ALPN: &[u8] = b"pkf1";
|
||||||
|
|
||||||
|
fn server_from_der(
|
||||||
|
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||||
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
idle: std::time::Duration,
|
||||||
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||||
|
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||||
|
// one that presents none still connects (and is rejected at the app layer when
|
||||||
|
// pairing is required).
|
||||||
|
let mut rustls_cfg = rustls::ServerConfig::builder()
|
||||||
|
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||||
|
.with_single_cert(vec![cert_der], key_der)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||||
|
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||||
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
|
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
|
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
|
||||||
|
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
|
||||||
|
/// client persists once and presents on every connect so hosts can recognize it.
|
||||||
|
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
|
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the client certificate a connection presented (host side), if any.
|
||||||
|
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
|
||||||
|
let identity = conn.peer_identity()?;
|
||||||
|
let certs = identity
|
||||||
|
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
|
||||||
|
.ok()?;
|
||||||
|
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
||||||
|
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||||
|
use sha2::Digest;
|
||||||
|
sha2::Sha256::digest(cert_der).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
||||||
|
/// must match what the client's verifier computes from the DER on the wire).
|
||||||
|
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||||
|
Ok(cert_fingerprint(der.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
|
||||||
|
/// observed fingerprint off the slot and pin it on the next connect).
|
||||||
|
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
|
client_pinned(None).0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
|
||||||
|
/// observed host fingerprint into during the handshake.
|
||||||
|
pub type PinnedClient = (
|
||||||
|
anyhow_result::Result<quinn::Endpoint>,
|
||||||
|
Arc<Mutex<Option<[u8; 32]>>>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Client endpoint that verifies the host by certificate fingerprint.
|
||||||
|
///
|
||||||
|
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
|
||||||
|
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
|
||||||
|
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
|
||||||
|
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
|
||||||
|
client_pinned_with_identity(pin, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
|
||||||
|
/// key) via TLS client auth — how a paired client identifies itself to the host.
|
||||||
|
pub fn client_pinned_with_identity(
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
identity: Option<(&str, &str)>,
|
||||||
|
) -> PinnedClient {
|
||||||
|
let observed = Arc::new(Mutex::new(None));
|
||||||
|
let ep = (|| {
|
||||||
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
let builder = rustls::ClientConfig::builder()
|
||||||
|
.dangerous()
|
||||||
|
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
||||||
|
pin,
|
||||||
|
observed: observed.clone(),
|
||||||
|
}));
|
||||||
|
let mut rustls_cfg = match identity {
|
||||||
|
None => builder.with_no_client_auth(),
|
||||||
|
Some((cert_pem, key_pem)) => {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let cert =
|
||||||
|
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client cert pem: {e}")))?;
|
||||||
|
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
|
||||||
|
builder
|
||||||
|
.with_client_auth_cert(vec![cert], key)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Must match the server's ALPN ([`QUIC_ALPN`]) or the handshake is rejected.
|
||||||
|
rustls_cfg.alpn_protocols = vec![QUIC_ALPN.to_vec()];
|
||||||
|
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||||
|
let mut client_cfg = quinn::ClientConfig::new(Arc::new(quic_cfg));
|
||||||
|
client_cfg.transport_config(stream_transport()); // keep-alive — see stream_transport
|
||||||
|
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||||
|
ep.set_default_client_config(client_cfg);
|
||||||
|
Ok(ep)
|
||||||
|
})();
|
||||||
|
(ep, observed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal error plumbing without pulling anyhow into punktfunk-core's public API.
|
||||||
|
pub mod anyhow_result {
|
||||||
|
pub type Result<T> = std::result::Result<T, Error>;
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Error(String);
|
||||||
|
impl Error {
|
||||||
|
pub fn msg(s: String) -> Self {
|
||||||
|
Error(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::fmt::Display for Error {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl std::error::Error for Error {}
|
||||||
|
impl From<std::io::Error> for Error {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
Error(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
||||||
|
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
||||||
|
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
||||||
|
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
||||||
|
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||||
|
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||||
|
/// Authorization (is this fingerprint paired?) happens at the application layer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AcceptAnyClientCert;
|
||||||
|
|
||||||
|
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||||
|
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_auth_mandatory(&self) -> bool {
|
||||||
|
false // unpaired/legacy clients still connect; gating is per-feature
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_client_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::server::danger::ClientCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct PinVerify {
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||||
|
fn verify_server_cert(
|
||||||
|
&self,
|
||||||
|
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||||
|
_ocsp: &[u8],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
let fp = cert_fingerprint(end_entity.as_ref());
|
||||||
|
*self.observed.lock().unwrap() = Some(fp);
|
||||||
|
if let Some(expected) = self.pin {
|
||||||
|
if fp != expected {
|
||||||
|
return Err(rustls::Error::InvalidCertificate(
|
||||||
|
rustls::CertificateError::ApplicationVerificationFailure,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handshake signatures MUST be verified for real even though we pin the cert:
|
||||||
|
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
|
||||||
|
// skip it and an active MITM can replay the host's (public) certificate, match the
|
||||||
|
// pin, and complete the handshake with its own key.
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||||
|
pub async fn read_msg(recv: &mut quinn::RecvStream) -> std::io::Result<Vec<u8>> {
|
||||||
|
let mut len = [0u8; 2];
|
||||||
|
recv.read_exact(&mut len)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
let n = u16::from_le_bytes(len) as usize;
|
||||||
|
let mut buf = vec![0u8; n];
|
||||||
|
recv.read_exact(&mut buf)
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write one framed message.
|
||||||
|
pub async fn write_msg(send: &mut quinn::SendStream, payload: &[u8]) -> std::io::Result<()> {
|
||||||
|
send.write_all(&super::frame(payload))
|
||||||
|
.await
|
||||||
|
.map_err(std::io::Error::other)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
//! `punktfunk/1` — the native control plane, gated behind the `quic` feature.
|
||||||
|
//!
|
||||||
|
//! GameStream is punktfunk's compatibility layer; this is the start of its own protocol. A QUIC
|
||||||
|
//! connection (quinn, tokio — control plane only, never the per-frame path) carries a
|
||||||
|
//! length-prefixed binary handshake on one bidirectional stream:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! client → host Hello { abi_version }
|
||||||
|
//! host → client Welcome { abi_version, session: full data-plane Config + mode + UDP port }
|
||||||
|
//! client → host Start { client_udp_port }
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! after which both sides bring up a [`crate::session::Session`] over a plain
|
||||||
|
//! [`UdpTransport`](crate::transport::udp) (native threads, no async) and the host streams.
|
||||||
|
//! The Welcome carries everything the core negotiates — FEC scheme (including GF(2¹⁶)
|
||||||
|
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
|
||||||
|
//! plane is exactly the hardened core `Session`.
|
||||||
|
//!
|
||||||
|
//! Transport security: the host presents a long-lived self-signed certificate
|
||||||
|
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
|
||||||
|
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
|
||||||
|
//! reported back for persisting). The data plane adds AES-GCM on top.
|
||||||
|
//! All integers little-endian; every message is `u16 length || payload`.
|
||||||
|
//!
|
||||||
|
//! Split by concern (networking-audit deferred plan §3 — a pure move): [`msgs`] the
|
||||||
|
//! handshake + typed control messages, [`pake`] the pairing SPAKE2, [`datagram`] the
|
||||||
|
//! 0xC9–0xCF plane codecs, [`io`] framed stream IO, [`clock`] skew estimation + mid-stream
|
||||||
|
//! re-sync, [`endpoint`] the quinn constructors. Every item is re-exported here, so all
|
||||||
|
//! existing `crate::quic::X` paths compile unchanged.
|
||||||
|
|
||||||
|
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
||||||
|
pub const MAGIC: &[u8; 4] = b"PKF1";
|
||||||
|
|
||||||
|
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
|
||||||
|
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
|
||||||
|
/// byte sits where a type byte would) can never be misparsed as a control message, and
|
||||||
|
/// vice-versa, regardless of field values.
|
||||||
|
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
|
||||||
|
|
||||||
|
mod clock;
|
||||||
|
mod datagram;
|
||||||
|
mod msgs;
|
||||||
|
|
||||||
|
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
|
||||||
|
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
|
||||||
|
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
|
||||||
|
pub mod endpoint;
|
||||||
|
|
||||||
|
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||||
|
pub mod io;
|
||||||
|
|
||||||
|
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||||
|
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||||
|
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||||
|
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
|
||||||
|
/// cannot reach a shared key).
|
||||||
|
pub mod pake;
|
||||||
|
|
||||||
|
pub use clock::*;
|
||||||
|
pub use datagram::*;
|
||||||
|
pub use msgs::*;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
|||||||
|
use crate::error::{PunktfunkError, Result};
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
use spake2::{Ed25519Group, Identity, Password, Spake2};
|
||||||
|
|
||||||
|
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
|
||||||
|
pub struct PairingPake {
|
||||||
|
state: Spake2<Ed25519Group>,
|
||||||
|
transcript: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
|
||||||
|
/// client passes what it observed via TOFU; the host passes its own + the client's
|
||||||
|
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
|
||||||
|
pub fn start(
|
||||||
|
is_client: bool,
|
||||||
|
pin: &str,
|
||||||
|
client_fp: &[u8; 32],
|
||||||
|
host_fp: &[u8; 32],
|
||||||
|
) -> (PairingPake, Vec<u8>) {
|
||||||
|
let pw = Password::new(pin.as_bytes());
|
||||||
|
let id_client = Identity::new(client_fp);
|
||||||
|
let id_host = Identity::new(host_fp);
|
||||||
|
let (state, msg) = if is_client {
|
||||||
|
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
|
||||||
|
} else {
|
||||||
|
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
|
||||||
|
};
|
||||||
|
let mut transcript = Vec::with_capacity(64);
|
||||||
|
transcript.extend_from_slice(client_fp);
|
||||||
|
transcript.extend_from_slice(host_fp);
|
||||||
|
(PairingPake { state, transcript }, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
|
||||||
|
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
|
||||||
|
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
|
||||||
|
let mut mac =
|
||||||
|
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
|
||||||
|
mac.update(label);
|
||||||
|
mac.update(transcript);
|
||||||
|
mac.finalize().into_bytes().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
|
||||||
|
/// our recomputed tag the same way.
|
||||||
|
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
||||||
|
a.iter()
|
||||||
|
.zip(b.iter())
|
||||||
|
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||||
|
== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
|
||||||
|
pub struct Confirmations {
|
||||||
|
/// MAC the host sends (client verifies).
|
||||||
|
pub host: [u8; 32],
|
||||||
|
/// MAC the client sends (host verifies).
|
||||||
|
pub client: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairingPake {
|
||||||
|
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
|
||||||
|
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
|
||||||
|
/// *different* key, so the confirmation MACs simply won't match).
|
||||||
|
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
|
||||||
|
let key = self
|
||||||
|
.state
|
||||||
|
.finish(peer_msg)
|
||||||
|
.map_err(|_| PunktfunkError::Crypto)?;
|
||||||
|
Ok(Confirmations {
|
||||||
|
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
|
||||||
|
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Constant-time tag comparison for the confirmation step.
|
||||||
|
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
|
||||||
|
ct_eq(expected, got)
|
||||||
|
}
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::config::{CompositorPref, FecConfig, FecScheme, GamepadPref, Mode, Role};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn welcome_roundtrip() {
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 1,
|
||||||
|
udp_port: 9999,
|
||||||
|
mode: Mode {
|
||||||
|
width: 2560,
|
||||||
|
height: 1440,
|
||||||
|
refresh_hz: 240,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [7u8; 16],
|
||||||
|
salt: [1, 2, 3, 4],
|
||||||
|
frames: 600,
|
||||||
|
compositor: CompositorPref::Gamescope,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 50_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264, // exercise a non-default codec through the roundtrip
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||||
|
|
||||||
|
// Client-side reassembler ceiling derives from the negotiated rate: 4x the average frame at
|
||||||
|
// 50 Mbps/240 Hz is ~104 KB, so the 8 MiB floor governs. The host keeps the p1_defaults
|
||||||
|
// bound (it never reassembles video), as does a client of a bitrate-0 (older) host.
|
||||||
|
let cc = w.session_config(Role::Client);
|
||||||
|
assert_eq!(cc.max_frame_bytes, 8 << 20);
|
||||||
|
cc.validate().expect("derived client config validates");
|
||||||
|
assert_eq!(w.session_config(Role::Host).max_frame_bytes, 64 << 20);
|
||||||
|
let old_host = Welcome {
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
..w
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
old_host.session_config(Role::Client).max_frame_bytes,
|
||||||
|
64 << 20
|
||||||
|
);
|
||||||
|
// A high-rate mode scales past the floor: 1.5 Gbps at 60 Hz = 4 x 3.125 MB = 12.5 MB.
|
||||||
|
let fat = Welcome {
|
||||||
|
bitrate_kbps: 1_500_000,
|
||||||
|
mode: Mode {
|
||||||
|
width: 5120,
|
||||||
|
height: 1440,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
..w
|
||||||
|
};
|
||||||
|
let derived = fat.session_config(Role::Client).max_frame_bytes;
|
||||||
|
assert_eq!(derived, 4 * 1_500_000 * 125 / 60);
|
||||||
|
assert!(derived > (8 << 20) && derived < (64 << 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn codec_negotiation_and_back_compat() {
|
||||||
|
// resolve_codec precedence (HEVC > AV1 > H.264), no preference (0).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_HEVC | CODEC_AV1, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_AV1, CODEC_AV1 | CODEC_H264, 0),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(CODEC_H264, CODEC_H264, 0), Some(CODEC_H264));
|
||||||
|
// A software host (H.264 only) + an HEVC-only client share nothing → refuse.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, 0), None);
|
||||||
|
// An older client (0 = no codec byte) is treated as HEVC-only.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(0, CODEC_HEVC | CODEC_H264, 0),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
assert_eq!(resolve_codec(0, CODEC_H264, 0), None);
|
||||||
|
|
||||||
|
// Soft preference: honored when the host can also emit it, overriding precedence...
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_H264 | CODEC_HEVC, CODEC_H264 | CODEC_HEVC, CODEC_H264),
|
||||||
|
Some(CODEC_H264)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_AV1, CODEC_HEVC | CODEC_AV1, CODEC_AV1),
|
||||||
|
Some(CODEC_AV1)
|
||||||
|
);
|
||||||
|
// ...but falls back to precedence when the preferred codec isn't in the shared set.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_codec(CODEC_HEVC | CODEC_H264, CODEC_HEVC | CODEC_H264, CODEC_AV1),
|
||||||
|
Some(CODEC_HEVC)
|
||||||
|
);
|
||||||
|
// A preference the host can't emit still can't rescue a no-shared-codec case.
|
||||||
|
assert_eq!(resolve_codec(CODEC_HEVC, CODEC_H264, CODEC_HEVC), None);
|
||||||
|
|
||||||
|
// A Hello advertising codecs roundtrips, and the wire form of a codec-only Hello decodes on
|
||||||
|
// a build that ignores the trailing byte (back-compat: extra bytes are skipped).
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC,
|
||||||
|
preferred_codec: CODEC_H264,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
let dec = Hello::decode(&enc).unwrap();
|
||||||
|
assert_eq!(dec.video_codecs, CODEC_H264 | CODEC_HEVC);
|
||||||
|
assert_eq!(dec.preferred_codec, CODEC_H264);
|
||||||
|
// Drop the preferred_codec byte → still decodes, video_codecs intact, preference gone.
|
||||||
|
let no_pref = &enc[..enc.len() - 1];
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(no_pref).unwrap().video_codecs,
|
||||||
|
CODEC_H264 | CODEC_HEVC
|
||||||
|
);
|
||||||
|
assert_eq!(Hello::decode(no_pref).unwrap().preferred_codec, 0);
|
||||||
|
// A pre-codec Hello (no video_codecs/preferred bytes) decodes to 0 → HEVC-only.
|
||||||
|
let legacy = &enc[..enc.len() - 2];
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().video_codecs, 0);
|
||||||
|
assert_eq!(Hello::decode(legacy).unwrap().preferred_codec, 0);
|
||||||
|
|
||||||
|
// A pre-codec Welcome (no codec byte) decodes to HEVC.
|
||||||
|
let mut w = Welcome::decode(
|
||||||
|
&Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 1,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 0,
|
||||||
|
max_data_per_block: 1024,
|
||||||
|
},
|
||||||
|
shard_payload: 1024,
|
||||||
|
encrypt: false,
|
||||||
|
key: [0; 16],
|
||||||
|
salt: [0; 4],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
bit_depth: 8,
|
||||||
|
color: ColorInfo::SDR_BT709,
|
||||||
|
chroma_format: CHROMA_IDC_420,
|
||||||
|
audio_channels: 2,
|
||||||
|
codec: CODEC_H264,
|
||||||
|
host_caps: 0,
|
||||||
|
}
|
||||||
|
.encode(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(w.codec, CODEC_H264);
|
||||||
|
w.codec = CODEC_HEVC;
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc[..wenc.len() - 1]).unwrap().codec,
|
||||||
|
CODEC_HEVC
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hdr_meta_datagram_roundtrip_and_truncation() {
|
||||||
|
let m = HdrMeta {
|
||||||
|
// BT.2020 display primaries in 1/50000 units (the DXGI/ST.2086 reference values).
|
||||||
|
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
||||||
|
white_point: [15635, 16450], // D65
|
||||||
|
max_display_mastering_luminance: 10_000_000, // 1000 nits in 0.0001 cd/m²
|
||||||
|
min_display_mastering_luminance: 1, // 0.0001 nits
|
||||||
|
max_cll: 1000,
|
||||||
|
max_fall: 400,
|
||||||
|
};
|
||||||
|
let d = encode_hdr_meta_datagram(&m);
|
||||||
|
assert_eq!(d[0], HDR_META_MAGIC);
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d), Some(m));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HIDOUT_MAGIC;
|
||||||
|
assert_eq!(decode_hdr_meta_datagram(&bad), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_timing_datagram_roundtrip_and_truncation() {
|
||||||
|
let t = HostTiming {
|
||||||
|
pts_ns: 1_751_500_000_123_456_789, // a realistic 2026 CLOCK_REALTIME capture stamp
|
||||||
|
host_us: 4_321,
|
||||||
|
};
|
||||||
|
let d = encode_host_timing_datagram(&t);
|
||||||
|
assert_eq!(d[0], HOST_TIMING_MAGIC);
|
||||||
|
assert_eq!(d.len(), 13);
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d), Some(t));
|
||||||
|
// Truncated buffers and a wrong tag are rejected (never partially read).
|
||||||
|
for n in 0..d.len() {
|
||||||
|
assert_eq!(decode_host_timing_datagram(&d[..n]), None);
|
||||||
|
}
|
||||||
|
let mut bad = d.clone();
|
||||||
|
bad[0] = HDR_META_MAGIC;
|
||||||
|
assert_eq!(decode_host_timing_datagram(&bad), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_start_roundtrip() {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 1,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 120,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 25_000,
|
||||||
|
name: Some("Test Device".into()),
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
video_caps: VIDEO_CAP_10BIT,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
|
||||||
|
preferred_codec: CODEC_HEVC,
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||||
|
let s = Start {
|
||||||
|
client_udp_port: 1234,
|
||||||
|
};
|
||||||
|
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compositor_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
CompositorPref::Auto,
|
||||||
|
CompositorPref::Kwin,
|
||||||
|
CompositorPref::Wlroots,
|
||||||
|
CompositorPref::Mutter,
|
||||||
|
CompositorPref::Gamescope,
|
||||||
|
] {
|
||||||
|
assert_eq!(CompositorPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(CompositorPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin));
|
||||||
|
assert_eq!(
|
||||||
|
CompositorPref::from_name("sway"),
|
||||||
|
Some(CompositorPref::Wlroots)
|
||||||
|
);
|
||||||
|
assert_eq!(CompositorPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamepad_pref_wire_and_names() {
|
||||||
|
for p in [
|
||||||
|
GamepadPref::Auto,
|
||||||
|
GamepadPref::Xbox360,
|
||||||
|
GamepadPref::DualSense,
|
||||||
|
GamepadPref::XboxOne,
|
||||||
|
GamepadPref::DualShock4,
|
||||||
|
] {
|
||||||
|
assert_eq!(GamepadPref::from_u8(p.to_u8()), p);
|
||||||
|
assert_eq!(GamepadPref::from_name(p.as_str()), Some(p));
|
||||||
|
}
|
||||||
|
// Distinct wire bytes (forward-compat with peers that only know 0..=2).
|
||||||
|
assert_eq!(GamepadPref::XboxOne.to_u8(), 3);
|
||||||
|
assert_eq!(GamepadPref::DualShock4.to_u8(), 4);
|
||||||
|
// Aliases + unknowns.
|
||||||
|
assert_eq!(GamepadPref::from_name("PS5"), Some(GamepadPref::DualSense));
|
||||||
|
assert_eq!(GamepadPref::from_name("x360"), Some(GamepadPref::Xbox360));
|
||||||
|
assert_eq!(GamepadPref::from_name("ps4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(GamepadPref::from_name("DS4"), Some(GamepadPref::DualShock4));
|
||||||
|
assert_eq!(
|
||||||
|
GamepadPref::from_name("xbox-one"),
|
||||||
|
Some(GamepadPref::XboxOne)
|
||||||
|
);
|
||||||
|
assert_eq!(GamepadPref::from_name("series"), Some(GamepadPref::XboxOne));
|
||||||
|
assert_eq!(GamepadPref::from_name("nope"), None);
|
||||||
|
// Unknown wire byte degrades to Auto (forward-compatible).
|
||||||
|
assert_eq!(GamepadPref::from_u8(200), GamepadPref::Auto);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_welcome_compositor_back_compat() {
|
||||||
|
// Trailing optional bytes (compositor at 20/53, gamepad at 21/54): a legacy peer's
|
||||||
|
// shorter message still decodes (missing fields = Auto), and a legacy peer reading a
|
||||||
|
// new message ignores the trailing bytes. Simulate both directions by truncation.
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Mutter,
|
||||||
|
gamepad: GamepadPref::DualSense,
|
||||||
|
bitrate_kbps: 80_000,
|
||||||
|
name: None,
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
};
|
||||||
|
let enc = h.encode();
|
||||||
|
assert_eq!(enc.len(), 26);
|
||||||
|
// Legacy (20-byte) Hello → both Auto, no bitrate, mode intact.
|
||||||
|
let legacy = Hello::decode(&enc[..20]).unwrap();
|
||||||
|
assert_eq!(legacy.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy.mode, h.mode);
|
||||||
|
// Compositor-era (21-byte) Hello → compositor intact, gamepad Auto.
|
||||||
|
let mid = Hello::decode(&enc[..21]).unwrap();
|
||||||
|
assert_eq!(mid.compositor, CompositorPref::Mutter);
|
||||||
|
assert_eq!(mid.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (22-byte) Hello → compositor + gamepad intact, bitrate 0 (host default).
|
||||||
|
let pre_bitrate = Hello::decode(&enc[..22]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate.gamepad, GamepadPref::DualSense);
|
||||||
|
assert_eq!(pre_bitrate.bitrate_kbps, 0);
|
||||||
|
// Full message → bitrate intact.
|
||||||
|
assert_eq!(Hello::decode(&enc).unwrap().bitrate_kbps, 80_000);
|
||||||
|
|
||||||
|
let w = Welcome {
|
||||||
|
abi_version: 2,
|
||||||
|
udp_port: 7000,
|
||||||
|
mode: h.mode,
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme: FecScheme::Gf16,
|
||||||
|
fec_percent: 20,
|
||||||
|
max_data_per_block: 4096,
|
||||||
|
},
|
||||||
|
shard_payload: 1200,
|
||||||
|
encrypt: true,
|
||||||
|
key: [3u8; 16],
|
||||||
|
salt: [9, 8, 7, 6],
|
||||||
|
frames: 0,
|
||||||
|
compositor: CompositorPref::Kwin,
|
||||||
|
gamepad: GamepadPref::Xbox360,
|
||||||
|
bitrate_kbps: 120_000,
|
||||||
|
bit_depth: 10,
|
||||||
|
color: ColorInfo::HDR10_BT2020_PQ,
|
||||||
|
chroma_format: CHROMA_IDC_444,
|
||||||
|
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||||
|
codec: CODEC_HEVC,
|
||||||
|
host_caps: HOST_CAP_GAMEPAD_STATE,
|
||||||
|
};
|
||||||
|
let wenc = w.encode();
|
||||||
|
assert_eq!(wenc.len(), 68); // 60 base + 4 colour + chroma + audio-channels + codec + host-caps
|
||||||
|
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||||
|
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||||
|
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||||
|
assert_eq!(legacy_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(legacy_w.frames, 0);
|
||||||
|
assert_eq!(legacy_w.key, w.key);
|
||||||
|
let mid_w = Welcome::decode(&wenc[..54]).unwrap();
|
||||||
|
assert_eq!(mid_w.compositor, CompositorPref::Kwin);
|
||||||
|
assert_eq!(mid_w.gamepad, GamepadPref::Auto);
|
||||||
|
// Gamepad-era (55-byte) Welcome → gamepad intact, bitrate 0 (unknown).
|
||||||
|
let pre_bitrate_w = Welcome::decode(&wenc[..55]).unwrap();
|
||||||
|
assert_eq!(pre_bitrate_w.gamepad, GamepadPref::Xbox360);
|
||||||
|
assert_eq!(pre_bitrate_w.bitrate_kbps, 0);
|
||||||
|
assert_eq!(pre_bitrate_w.bit_depth, 8); // older host (no trailing byte) → 8-bit assumed
|
||||||
|
assert_eq!(legacy_w.bit_depth, 8);
|
||||||
|
// A pre-colour (60-byte) Welcome → SDR BT.709 (the only colour those hosts produced).
|
||||||
|
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||||
|
assert_eq!(pre_color_w.bit_depth, 10);
|
||||||
|
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
|
||||||
|
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||||
|
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
|
||||||
|
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
|
||||||
|
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
|
||||||
|
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
|
||||||
|
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
|
||||||
|
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
|
||||||
|
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
|
||||||
|
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
|
||||||
|
assert_eq!(pre_audio_w.audio_channels, 2);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().color,
|
||||||
|
ColorInfo::HDR10_BT2020_PQ
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().chroma_format,
|
||||||
|
CHROMA_IDC_444
|
||||||
|
); // full form carries 4:4:4
|
||||||
|
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||||
|
// A pre-host-caps (67-byte) Welcome → 0 (legacy input only); the full form carries the bit.
|
||||||
|
assert_eq!(Welcome::decode(&wenc[..67]).unwrap().host_caps, 0);
|
||||||
|
assert_eq!(
|
||||||
|
Welcome::decode(&wenc).unwrap().host_caps,
|
||||||
|
HOST_CAP_GAMEPAD_STATE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_name_roundtrip_and_back_compat() {
|
||||||
|
let base = Hello {
|
||||||
|
abi_version: 2,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
},
|
||||||
|
compositor: CompositorPref::Auto,
|
||||||
|
gamepad: GamepadPref::Auto,
|
||||||
|
bitrate_kbps: 0,
|
||||||
|
name: Some("Enrico's MacBook".into()),
|
||||||
|
launch: None,
|
||||||
|
video_caps: 0,
|
||||||
|
audio_channels: 2,
|
||||||
|
video_codecs: 0,
|
||||||
|
preferred_codec: 0,
|
||||||
|
};
|
||||||
|
let enc = base.encode();
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&enc).unwrap().name.as_deref(),
|
||||||
|
Some("Enrico's MacBook")
|
||||||
|
);
|
||||||
|
// A bitrate-era (26-byte) peer reading a named Hello ignores the trailing name; a named
|
||||||
|
// host reading a bitrate-era Hello decodes name = None.
|
||||||
|
assert_eq!(Hello::decode(&enc[..26]).unwrap().name, None);
|
||||||
|
// No name → wire form is byte-identical to the bitrate-era message (26 bytes).
|
||||||
|
let unnamed = Hello {
|
||||||
|
name: None,
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(unnamed.encode().len(), 26);
|
||||||
|
// Over-long names truncate to a char boundary within HELLO_NAME_MAX on encode.
|
||||||
|
let long = Hello {
|
||||||
|
name: Some(format!("{}ü", "x".repeat(HELLO_NAME_MAX - 1))), // ü straddles the cap
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode()).unwrap();
|
||||||
|
let n = dec.name.expect("truncated name still present");
|
||||||
|
assert!(n.len() <= HELLO_NAME_MAX && n.starts_with('x'));
|
||||||
|
// A corrupt length byte (longer than the buffer) or bad UTF-8 degrades to None, never Err.
|
||||||
|
let mut bad_len = unnamed.encode();
|
||||||
|
bad_len.push(40); // claims 40 name bytes, none follow
|
||||||
|
assert_eq!(Hello::decode(&bad_len).unwrap().name, None);
|
||||||
|
let mut bad_utf8 = unnamed.encode();
|
||||||
|
bad_utf8.extend_from_slice(&[2, 0xFF, 0xFE]);
|
||||||
|
assert_eq!(Hello::decode(&bad_utf8).unwrap().name, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hello_launch_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,
|
||||||
|
};
|
||||||
|
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||||
|
let with_launch = Hello {
|
||||||
|
launch: Some("steam:570".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&with_launch.encode()).unwrap(), with_launch);
|
||||||
|
// launch + name together.
|
||||||
|
let both = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
launch: Some("custom:abc123".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&both.encode()).unwrap(), both);
|
||||||
|
// name but no launch (a name-era client): launch decodes None.
|
||||||
|
let name_only = Hello {
|
||||||
|
name: Some("Enrico's Mac".into()),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(Hello::decode(&name_only.encode()).unwrap().launch, None);
|
||||||
|
// Neither field → still the 26-byte bitrate-era form (no launch placeholder emitted).
|
||||||
|
assert_eq!(base.encode().len(), 26);
|
||||||
|
assert_eq!(Hello::decode(&base.encode()).unwrap().launch, None);
|
||||||
|
// A bitrate-era (26-byte) peer reading a launch-bearing Hello ignores it.
|
||||||
|
assert_eq!(
|
||||||
|
Hello::decode(&with_launch.encode()[..26]).unwrap().launch,
|
||||||
|
None
|
||||||
|
);
|
||||||
|
// Over-long ids truncate on a char boundary within HELLO_LAUNCH_MAX.
|
||||||
|
let long = Hello {
|
||||||
|
launch: Some(format!("{}ü", "x".repeat(HELLO_LAUNCH_MAX - 1))),
|
||||||
|
..base.clone()
|
||||||
|
};
|
||||||
|
let dec = Hello::decode(&long.encode())
|
||||||
|
.unwrap()
|
||||||
|
.launch
|
||||||
|
.expect("present");
|
||||||
|
assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconfigure_roundtrip() {
|
||||||
|
let rq = Reconfigure {
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 144,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
|
||||||
|
for accepted in [true, false] {
|
||||||
|
let rs = Reconfigured {
|
||||||
|
accepted,
|
||||||
|
mode: rq.mode,
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
|
||||||
|
}
|
||||||
|
// The type byte separates the post-handshake messages from each other.
|
||||||
|
assert!(Reconfigure::decode(
|
||||||
|
&Reconfigured {
|
||||||
|
accepted: true,
|
||||||
|
mode: rq.mode
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_keyframe_roundtrip() {
|
||||||
|
let bytes = RequestKeyframe.encode();
|
||||||
|
assert!(RequestKeyframe::decode(&bytes).is_ok());
|
||||||
|
// Distinct from the other control messages — its type byte must not collide.
|
||||||
|
let mode = Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
assert!(RequestKeyframe::decode(&Reconfigure { mode }.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&bytes).is_err());
|
||||||
|
// Length is exact (no trailing bytes accepted).
|
||||||
|
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn loss_report_roundtrip() {
|
||||||
|
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||||
|
let r = LossReport { loss_ppm };
|
||||||
|
assert_eq!(LossReport::decode(&r.encode()).unwrap(), r);
|
||||||
|
}
|
||||||
|
// Disjoint from the other control messages (type byte + length).
|
||||||
|
assert!(LossReport::decode(&RequestKeyframe.encode()).is_err());
|
||||||
|
assert!(RequestKeyframe::decode(&LossReport { loss_ppm: 0 }.encode()).is_err());
|
||||||
|
assert!(
|
||||||
|
LossReport::decode(&[LossReport { loss_ppm: 0 }.encode().as_slice(), &[0]].concat())
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_loss_ppm_estimates_and_caps() {
|
||||||
|
// No traffic → 0. A clean window (nothing recovered) → 0.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 0), 0);
|
||||||
|
assert_eq!(window_loss_ppm(0, 1000, 0), 0);
|
||||||
|
// 50 recovered of 1000 total (950 received + 50 recovered) = 5%.
|
||||||
|
assert_eq!(window_loss_ppm(50, 950, 0), 50_000);
|
||||||
|
// An unrecoverable frame adds the +5% bump (push FEC past the current cap).
|
||||||
|
assert_eq!(window_loss_ppm(50, 950, 1), 100_000);
|
||||||
|
// A total-loss window with a drop but nothing received still reports the bump, capped at 1e6.
|
||||||
|
assert_eq!(window_loss_ppm(0, 0, 3), 50_000);
|
||||||
|
assert!(window_loss_ppm(u64::MAX, 1, 9) <= 1_000_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bitrate_messages_roundtrip() {
|
||||||
|
let req = SetBitrate {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(SetBitrate::decode(&req.encode()).unwrap(), req);
|
||||||
|
let ack = BitrateChanged {
|
||||||
|
bitrate_kbps: 14_000,
|
||||||
|
};
|
||||||
|
assert_eq!(BitrateChanged::decode(&ack.encode()).unwrap(), ack);
|
||||||
|
// Same payload shape as LossReport — the type byte alone must keep them disjoint.
|
||||||
|
assert!(LossReport::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&ack.encode()).is_err());
|
||||||
|
assert!(BitrateChanged::decode(&req.encode()).is_err());
|
||||||
|
assert!(SetBitrate::decode(&LossReport { loss_ppm: 7 }.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn probe_messages_roundtrip() {
|
||||||
|
let req = ProbeRequest {
|
||||||
|
target_kbps: 250_000,
|
||||||
|
duration_ms: 2000,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeRequest::decode(&req.encode()).unwrap(), req);
|
||||||
|
let res = ProbeResult {
|
||||||
|
bytes_sent: 62_500_000,
|
||||||
|
packets_sent: 480,
|
||||||
|
duration_ms: 2003,
|
||||||
|
wire_packets_sent: 41_000,
|
||||||
|
send_dropped: 1_200,
|
||||||
|
};
|
||||||
|
assert_eq!(ProbeResult::decode(&res.encode()).unwrap(), res);
|
||||||
|
assert_eq!(res.encode().len(), 29);
|
||||||
|
// A pre-wire-stats host's 21-byte ProbeResult still decodes, with the new fields zeroed.
|
||||||
|
let legacy = {
|
||||||
|
let full = res.encode();
|
||||||
|
full[..21].to_vec()
|
||||||
|
};
|
||||||
|
let decoded = ProbeResult::decode(&legacy).unwrap();
|
||||||
|
assert_eq!(decoded.wire_packets_sent, 0);
|
||||||
|
assert_eq!(decoded.send_dropped, 0);
|
||||||
|
assert_eq!(decoded.bytes_sent, res.bytes_sent);
|
||||||
|
// Type bytes keep the control messages disjoint from each other.
|
||||||
|
assert!(ProbeRequest::decode(&res.encode()).is_err());
|
||||||
|
assert!(Reconfigure::decode(&req.encode()).is_err());
|
||||||
|
assert!(ProbeResult::decode(&req.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_messages_roundtrip() {
|
||||||
|
let probe = ClockProbe {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockProbe::decode(&probe.encode()).unwrap(), probe);
|
||||||
|
let echo = ClockEcho {
|
||||||
|
t1_ns: 1_700_000_000_123,
|
||||||
|
t2_ns: 1_700_000_050_456,
|
||||||
|
t3_ns: 1_700_000_050_789,
|
||||||
|
};
|
||||||
|
assert_eq!(ClockEcho::decode(&echo.encode()).unwrap(), echo);
|
||||||
|
// Disjoint from the other control messages (distinct type bytes).
|
||||||
|
assert!(ClockProbe::decode(&echo.encode()).is_err());
|
||||||
|
assert!(ProbeRequest::decode(&probe.encode()).is_err());
|
||||||
|
assert!(ClockEcho::decode(&probe.encode()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clock_offset_picks_min_rtt_and_recovers_offset() {
|
||||||
|
// Host clock is +1_000_000 ns ahead of the client. Construct samples where a symmetric
|
||||||
|
// round-trip recovers exactly that offset, and a noisy (asymmetric, high-RTT) sample is
|
||||||
|
// present but must be ignored by the min-RTT selection.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
// Clean sample: client t1=0, one-way=200µs each way → t2 = t1 + 200_000 + OFF (host clock),
|
||||||
|
// t3 = t2 + 50_000 (host processing), t4 = t3 - OFF + 200_000 (back in client clock).
|
||||||
|
let t1 = 0u64;
|
||||||
|
let t2 = (t1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let t3 = t2 + 50_000;
|
||||||
|
let t4 = (t3 as i64 - OFF + 200_000) as u64;
|
||||||
|
// Noisy sample: same offset but a fat, asymmetric RTT (slow return path) — higher RTT.
|
||||||
|
let n1 = 1_000_000u64;
|
||||||
|
let n2 = (n1 as i64 + 200_000 + OFF) as u64;
|
||||||
|
let n3 = n2 + 50_000;
|
||||||
|
let n4 = (n3 as i64 - OFF + 5_000_000) as u64; // 5 ms return → big RTT
|
||||||
|
let (offset, rtt) = clock_offset_ns(&[(n1, n2, n3, n4), (t1, t2, t3, t4)]).expect("non-empty");
|
||||||
|
// The min-RTT sample recovers the offset exactly; its RTT is 2x200us, and the noisy
|
||||||
|
// (asymmetric, 5 ms return) sample is ignored by the min-RTT selection.
|
||||||
|
assert_eq!(offset, OFF);
|
||||||
|
assert_eq!(rtt, 400_000);
|
||||||
|
assert!(clock_offset_ns(&[]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mid-stream re-sync state machine: 8 rounds collected via matched echoes, stale
|
||||||
|
/// echoes ignored, a restarted batch abandons the old one, and the batch result is the
|
||||||
|
/// min-RTT estimate — the exact behavior the connect-time `clock_sync` loop has.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_collects_rounds_and_ignores_stale_echoes() {
|
||||||
|
// Host clock +1 ms ahead; symmetric 100 µs one-way paths except one congested round.
|
||||||
|
const OFF: i64 = 1_000_000;
|
||||||
|
let echo_for = |t1: u64, one_way: u64| ClockEcho {
|
||||||
|
t1_ns: t1,
|
||||||
|
t2_ns: (t1 as i64 + one_way as i64 + OFF) as u64,
|
||||||
|
t3_ns: (t1 as i64 + one_way as i64 + OFF) as u64 + 10_000,
|
||||||
|
};
|
||||||
|
let t4_for = |e: &ClockEcho, one_way: u64| (e.t3_ns as i64 - OFF + one_way as i64) as u64;
|
||||||
|
|
||||||
|
let mut rs = ClockResync::new();
|
||||||
|
// An unsolicited echo before any batch is ignored.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(42, 100_000), 500_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut probe = rs.begin(1_000_000);
|
||||||
|
// A stale echo (wrong t1: the abandoned pre-begin probe) is ignored mid-batch.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(42, 100_000), 500_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
for round in 0..ClockResync::ROUNDS {
|
||||||
|
// Round 3 is congested (5 ms one-way) — it must lose the min-RTT selection.
|
||||||
|
let one_way = if round == 3 { 5_000_000 } else { 100_000 };
|
||||||
|
let echo = echo_for(probe.t1_ns, one_way);
|
||||||
|
let t4 = t4_for(&echo, one_way);
|
||||||
|
match rs.on_echo(&echo, t4) {
|
||||||
|
ResyncStep::Probe(p) => {
|
||||||
|
assert!(round < ClockResync::ROUNDS - 1, "batch overran its rounds");
|
||||||
|
probe = p;
|
||||||
|
}
|
||||||
|
ResyncStep::Done { offset_ns, rtt_ns } => {
|
||||||
|
assert_eq!(round, ClockResync::ROUNDS - 1, "batch ended early");
|
||||||
|
assert_eq!(offset_ns, OFF, "min-RTT round recovers the offset exactly");
|
||||||
|
assert_eq!(rtt_ns, 200_000); // 2×100 µs; host processing (t3−t2) excluded
|
||||||
|
}
|
||||||
|
ResyncStep::Idle => panic!("matched echo must advance the batch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The batch is done: even a matching-t1 replay no longer advances anything.
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(probe.t1_ns, 100_000), probe.t1_ns + 300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
|
||||||
|
// begin() mid-batch abandons the in-flight batch: its echo is stale afterwards.
|
||||||
|
let old = rs.begin(2_000_000);
|
||||||
|
let fresh = rs.begin(3_000_000);
|
||||||
|
assert_eq!(
|
||||||
|
rs.on_echo(&echo_for(old.t1_ns, 100_000), 2_300_000),
|
||||||
|
ResyncStep::Idle
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
rs.on_echo(&echo_for(fresh.t1_ns, 100_000), 3_300_000),
|
||||||
|
ResyncStep::Probe(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The acceptance guard: a batch measured through a congested window (fat RTT) must not
|
||||||
|
/// replace the offset — its queueing delay biases the estimate exactly when frames
|
||||||
|
/// already read late. Floor of 2 ms so a near-zero connect RTT (same-host/LAN) doesn't
|
||||||
|
/// reject every later batch over normal jitter.
|
||||||
|
#[test]
|
||||||
|
fn clock_resync_acceptance_guard() {
|
||||||
|
// Generous connect RTT (10 ms): accept up to 1.5×.
|
||||||
|
assert!(accept_resync(14_000_000, 10_000_000));
|
||||||
|
assert!(!accept_resync(16_000_000, 10_000_000));
|
||||||
|
// Tiny connect RTT (200 µs, wired LAN): the 2 ms floor governs.
|
||||||
|
assert!(accept_resync(1_900_000, 200_000));
|
||||||
|
assert!(!accept_resync(2_100_000, 200_000));
|
||||||
|
// Boundary: exactly at the bound is accepted.
|
||||||
|
assert!(accept_resync(2_000_000, 0));
|
||||||
|
assert!(accept_resync(15_000_000, 10_000_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn control_messages_disjoint_from_hello() {
|
||||||
|
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
||||||
|
// any abi_version — can be misparsed as a control message, and vice-versa.
|
||||||
|
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
|
||||||
|
let h = Hello {
|
||||||
|
abi_version: abi,
|
||||||
|
mode: Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||||
|
assert!(Reconfigure::decode(&h).is_err());
|
||||||
|
}
|
||||||
|
// And a PairRequest never parses as a Hello.
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "x".into(),
|
||||||
|
spake_a: vec![0u8; 33],
|
||||||
|
}
|
||||||
|
.encode();
|
||||||
|
assert!(Hello::decode(&pr).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pair_messages_roundtrip() {
|
||||||
|
let pr = PairRequest {
|
||||||
|
name: "Enrico's Mac".into(),
|
||||||
|
spake_a: vec![1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
|
||||||
|
let pc = PairChallenge {
|
||||||
|
spake_b: vec![9; 33],
|
||||||
|
confirm: [7u8; 32],
|
||||||
|
};
|
||||||
|
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
|
||||||
|
let pp = PairProof { confirm: [3u8; 32] };
|
||||||
|
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
|
||||||
|
for ok in [true, false] {
|
||||||
|
assert_eq!(
|
||||||
|
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
|
||||||
|
ok
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Length-exact: a truncated/padded PairProof is rejected.
|
||||||
|
let mut bad = pp.encode();
|
||||||
|
bad.push(0);
|
||||||
|
assert!(PairProof::decode(&bad).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
|
||||||
|
let cfp = [0x11u8; 32];
|
||||||
|
let hfp = [0x22u8; 32];
|
||||||
|
|
||||||
|
// Right PIN, same fingerprint views on both sides → both confirmations agree.
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
|
||||||
|
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
|
||||||
|
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
|
||||||
|
let attacker_hfp = [0x33u8; 32];
|
||||||
|
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
|
||||||
|
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||||
|
let a = ca.finish(&mb).unwrap();
|
||||||
|
let b = cb.finish(&ma).unwrap();
|
||||||
|
assert!(!pake::verify(&a.client, &b.client));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn audio_datagram_roundtrip() {
|
||||||
|
let opus = [0x42u8; 97];
|
||||||
|
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();
|
||||||
|
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
|
||||||
|
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
|
||||||
|
|
||||||
|
// Empty payload is legal (DTX) — header-only datagram.
|
||||||
|
let header_only = encode_audio_datagram(0, 0, &[]);
|
||||||
|
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumble_datagram_roundtrip() {
|
||||||
|
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||||
|
assert_eq!(d[0], RUMBLE_MAGIC);
|
||||||
|
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
|
||||||
|
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mic_datagram_roundtrip_and_disjoint_from_audio() {
|
||||||
|
let opus = [0x5Au8; 80];
|
||||||
|
let d = encode_mic_datagram(42, 9_999, &opus);
|
||||||
|
assert_eq!(d[0], MIC_MAGIC);
|
||||||
|
let (seq, pts, payload) = decode_mic_datagram(&d).unwrap();
|
||||||
|
assert_eq!((seq, pts), (42, 9_999));
|
||||||
|
assert_eq!(payload, opus);
|
||||||
|
assert!(decode_mic_datagram(&d[..12]).is_none()); // truncated
|
||||||
|
// Tag separation: a mic datagram is not an audio datagram and vice-versa.
|
||||||
|
assert!(decode_audio_datagram(&d).is_none());
|
||||||
|
assert!(decode_mic_datagram(&encode_audio_datagram(1, 2, &opus)).is_none());
|
||||||
|
// Empty payload (DTX) is legal.
|
||||||
|
assert!(decode_mic_datagram(&encode_mic_datagram(0, 0, &[]))
|
||||||
|
.unwrap()
|
||||||
|
.2
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rich_input_roundtrip() {
|
||||||
|
for ev in [
|
||||||
|
RichInput::Touchpad {
|
||||||
|
pad: 1,
|
||||||
|
finger: 0,
|
||||||
|
active: true,
|
||||||
|
x: 40000,
|
||||||
|
y: 12345,
|
||||||
|
},
|
||||||
|
RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro: [-100, 200, -300],
|
||||||
|
accel: [16384, -8192, 1],
|
||||||
|
},
|
||||||
|
RichInput::TouchpadEx {
|
||||||
|
pad: 2,
|
||||||
|
surface: 1,
|
||||||
|
finger: 1,
|
||||||
|
touch: true,
|
||||||
|
click: false,
|
||||||
|
x: -12345,
|
||||||
|
y: 30000,
|
||||||
|
pressure: 4000,
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
let d = ev.encode();
|
||||||
|
assert_eq!(d[0], RICH_INPUT_MAGIC);
|
||||||
|
assert_eq!(RichInput::decode(&d), Some(ev));
|
||||||
|
}
|
||||||
|
// Disjoint from the fixed input datagram (0xC8); unknown kind + truncation → None.
|
||||||
|
assert!(RichInput::decode(&[crate::input::INPUT_MAGIC; 18]).is_none());
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD, 0]).is_none()); // short
|
||||||
|
assert!(RichInput::decode(&[RICH_INPUT_MAGIC, RICH_TOUCHPAD_EX, 0, 0, 0, 0]).is_none());
|
||||||
|
// short
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hid_output_roundtrip() {
|
||||||
|
let cases = [
|
||||||
|
HidOutput::Led {
|
||||||
|
pad: 2,
|
||||||
|
r: 0xAA,
|
||||||
|
g: 0xBB,
|
||||||
|
b: 0xCC,
|
||||||
|
},
|
||||||
|
HidOutput::PlayerLeds {
|
||||||
|
pad: 0,
|
||||||
|
bits: 0b10101,
|
||||||
|
},
|
||||||
|
HidOutput::Trigger {
|
||||||
|
pad: 1,
|
||||||
|
which: 1,
|
||||||
|
effect: vec![0x26, 0x90, 0xA0, 0xFF, 0x00, 0x00],
|
||||||
|
},
|
||||||
|
HidOutput::TrackpadHaptic {
|
||||||
|
pad: 0,
|
||||||
|
side: 1,
|
||||||
|
amplitude: 0x1234,
|
||||||
|
period: 0x5678,
|
||||||
|
count: 9,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for ev in &cases {
|
||||||
|
let d = ev.encode();
|
||||||
|
assert_eq!(d[0], HIDOUT_MAGIC);
|
||||||
|
assert_eq!(HidOutput::decode(&d).as_ref(), Some(ev));
|
||||||
|
}
|
||||||
|
assert!(HidOutput::decode(&[HIDOUT_MAGIC, 0x7F]).is_none()); // unknown kind
|
||||||
|
// A rich-input datagram is not a HID-output datagram.
|
||||||
|
assert!(HidOutput::decode(
|
||||||
|
&RichInput::Motion {
|
||||||
|
pad: 0,
|
||||||
|
gyro: [0; 3],
|
||||||
|
accel: [0; 3]
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_sha256_of_der() {
|
||||||
|
// Stable across calls, distinct for distinct certs.
|
||||||
|
let a = endpoint::cert_fingerprint(b"cert-a");
|
||||||
|
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||||
|
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use crate::input::InputEvent;
|
|||||||
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
|
use crate::packet::{Packetizer, Reassembler, ReassemblerLimits, MAX_DATAGRAM_BYTES};
|
||||||
use crate::stats::{Stats, StatsCounters};
|
use crate::stats::{Stats, StatsCounters};
|
||||||
use crate::transport::Transport;
|
use crate::transport::Transport;
|
||||||
|
use zerocopy::IntoBytes;
|
||||||
|
|
||||||
/// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder.
|
/// A reassembled, FEC-recovered access unit, ready to hand to the platform decoder.
|
||||||
pub struct Frame {
|
pub struct Frame {
|
||||||
@@ -166,18 +167,57 @@ impl Session {
|
|||||||
"seal_frame called on a client session",
|
"seal_frame called on a client session",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let packets = self
|
// Packetize straight into the pooled wire buffers (reused across frames via
|
||||||
.packetizer
|
// `reclaim_wires`) and seal each in place: the plaintext `header ++ shard` is written
|
||||||
.packetize(data, pts_ns, user_flags, self.coder.as_ref())?;
|
// once, at its final wire offset — no intermediate per-packet Vec at all. Byte-identical
|
||||||
StatsCounters::add(&self.stats.frames_submitted, 1);
|
// to the wrapper (`packetize` + seal) path: same plaintext, same emission order, and the
|
||||||
// Reuse the wire-buffer pool the caller returns via `reclaim_wires`: one buffer per packet,
|
// nonce counter advances per emitted packet exactly as before (pinned by the
|
||||||
// sealed in place — after warmup there is no per-packet ciphertext/wire allocation. (`wires`
|
// wire-equivalence tests below). Destructure into disjoint field borrows first — the
|
||||||
// is a local, so `seal_into`'s `&mut self` doesn't alias the `&mut` iteration over it.)
|
// emit closure needs `crypto`/`next_seq`/the pool while `packetizer` is `&mut`.
|
||||||
let mut wires = std::mem::take(&mut self.wire_pool);
|
let Session {
|
||||||
wires.resize_with(packets.len(), Vec::new);
|
packetizer,
|
||||||
for (wire, pkt) in wires.iter_mut().zip(packets.iter()) {
|
coder,
|
||||||
self.seal_into(pkt, wire)?;
|
crypto,
|
||||||
|
next_seq,
|
||||||
|
wire_pool,
|
||||||
|
..
|
||||||
|
} = self;
|
||||||
|
let mut wires = std::mem::take(wire_pool);
|
||||||
|
let mut used = 0usize;
|
||||||
|
let result = packetizer.packetize_each(data, pts_ns, user_flags, coder.as_ref(), {
|
||||||
|
let wires = &mut wires;
|
||||||
|
let used = &mut used;
|
||||||
|
move |hdr, body| {
|
||||||
|
if *used == wires.len() {
|
||||||
|
wires.push(Vec::new());
|
||||||
}
|
}
|
||||||
|
let wire = &mut wires[*used];
|
||||||
|
*used += 1;
|
||||||
|
let seq = *next_seq;
|
||||||
|
*next_seq = next_seq.wrapping_add(1);
|
||||||
|
wire.clear();
|
||||||
|
match crypto {
|
||||||
|
Some(c) => {
|
||||||
|
// seq(8) ‖ header(40) ‖ shard ‖ tag scratch(16), sealed over [8..].
|
||||||
|
wire.extend_from_slice(&seq.to_be_bytes());
|
||||||
|
wire.extend_from_slice(hdr.as_bytes());
|
||||||
|
wire.extend_from_slice(body);
|
||||||
|
wire.resize(wire.len() + crate::crypto::TAG_LEN, 0);
|
||||||
|
c.seal_in_place(seq, &mut wire[8..])?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
wire.extend_from_slice(hdr.as_bytes());
|
||||||
|
wire.extend_from_slice(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
result?;
|
||||||
|
// A smaller frame uses fewer buffers than the pool holds: drop the unused tail, same
|
||||||
|
// as the previous `resize_with(packets.len(), ..)` did.
|
||||||
|
wires.truncate(used);
|
||||||
|
StatsCounters::add(&self.stats.frames_submitted, 1);
|
||||||
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
let bytes: u64 = wires.iter().map(|w| w.len() as u64).sum();
|
||||||
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
StatsCounters::add(&self.stats.packets_sent, wires.len() as u64);
|
||||||
StatsCounters::add(&self.stats.bytes_sent, bytes);
|
StatsCounters::add(&self.stats.bytes_sent, bytes);
|
||||||
@@ -296,24 +336,42 @@ impl Session {
|
|||||||
if len > MAX_DATAGRAM_BYTES {
|
if len > MAX_DATAGRAM_BYTES {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let pkt = match self.open_from_wire(&self.recv_scratch[i][..len]) {
|
// Open in place inside the ring buffer — no per-datagram allocation at line rate
|
||||||
Ok(p) => p,
|
// (~125k pkt/s at 1 Gbps; the recv ring killed the recv alloc, this kills the decrypt
|
||||||
Err(_) => continue,
|
// one). The plaintext lands at [8..8+n] of the sealed wire (behind the seq prefix); an
|
||||||
|
// unencrypted (probe) datagram IS the packet. Field-precise borrows keep the slice into
|
||||||
|
// `recv_scratch` alive across the replay/reassembler calls below.
|
||||||
|
let (pkt_range, seq) = match &self.crypto {
|
||||||
|
Some(c) => {
|
||||||
|
// A sealed datagram is at least seq prefix + tag; anything shorter is noise.
|
||||||
|
if len < 8 + crate::crypto::TAG_LEN {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let seq = u64::from_be_bytes(self.recv_scratch[i][..8].try_into().unwrap());
|
||||||
|
match c.open_in_place(seq, &mut self.recv_scratch[i][8..len]) {
|
||||||
|
Ok(n) => (8..8 + n, Some(seq)),
|
||||||
|
Err(_) => continue, // undecryptable noise — drop, keep draining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => (0..len, None),
|
||||||
};
|
};
|
||||||
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
|
// Anti-replay (same rationale as poll_input): reject a datagram whose authenticated
|
||||||
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
|
// sequence was already seen. Video also dedups per-frame downstream, but filtering here
|
||||||
// is uniform and cheap. `len >= 8` because the sealed-path open above succeeded.
|
// is uniform and cheap.
|
||||||
if self.replay.is_some() && !self.accept_seq(seq_of(&self.recv_scratch[i][..len])) {
|
if let (Some(w), Some(seq)) = (self.replay.as_mut(), seq) {
|
||||||
|
if !w.accept(seq) {
|
||||||
StatsCounters::add(&self.stats.packets_dropped, 1);
|
StatsCounters::add(&self.stats.packets_dropped, 1);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
let pkt = &self.recv_scratch[i][pkt_range];
|
||||||
StatsCounters::add(&self.stats.packets_received, 1);
|
StatsCounters::add(&self.stats.packets_received, 1);
|
||||||
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
StatsCounters::add(&self.stats.bytes_received, pkt.len() as u64);
|
||||||
// The reassembler validates the packet via its parsed header (`magic`),
|
// The reassembler validates the packet via its parsed header (`magic`),
|
||||||
// ignoring anything that isn't a well-formed video packet.
|
// ignoring anything that isn't a well-formed video packet.
|
||||||
if let Some(frame) = self
|
if let Some(frame) = self
|
||||||
.reassembler
|
.reassembler
|
||||||
.push(&pkt, self.coder.as_ref(), &self.stats)?
|
.push(pkt, self.coder.as_ref(), &self.stats)?
|
||||||
{
|
{
|
||||||
StatsCounters::add(&self.stats.frames_completed, 1);
|
StatsCounters::add(&self.stats.frames_completed, 1);
|
||||||
return Ok(frame);
|
return Ok(frame);
|
||||||
@@ -387,10 +445,14 @@ fn seq_of(wire: &[u8]) -> u64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
|
/// Depth of the anti-replay window, in sequences. The sender advances its sequence once per
|
||||||
/// datagram, so at the data plane's packet rate 4096 is roughly 33 ms of reorder tolerance for the
|
/// datagram, so this must cover the reassembler's 120 ms loss window
|
||||||
/// video stream (well beyond any reordering still useful for a live frame) and effectively unbounded
|
/// ([`LOSS_WINDOW_NS`](crate::packet)) at line-rate packet rates — otherwise the replay filter
|
||||||
/// for the sparse input stream — while bounding how far back a replay could hide.
|
/// silently re-tightens the "late ≠ lost" fix: a Wi-Fi-retry-delayed shard the reassembler would
|
||||||
const REPLAY_WINDOW: u64 = 4096;
|
/// still use gets dropped here as "older than the window" first (4096 was only ~33 ms at the
|
||||||
|
/// ~125k pkt/s of a 1 Gbps stream). 32768 covers 120 ms up to ~270k pkt/s (≈2 Gbps+) and is
|
||||||
|
/// effectively unbounded for the sparse input stream, while still bounding how far back a replay
|
||||||
|
/// could hide; the bitmap costs 4 KiB per session.
|
||||||
|
const REPLAY_WINDOW: u64 = 32768;
|
||||||
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
const REPLAY_WORDS: usize = (REPLAY_WINDOW / 64) as usize;
|
||||||
|
|
||||||
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
/// Sliding-window anti-replay filter over the AEAD-authenticated wire sequence. The sender counts
|
||||||
@@ -469,6 +531,96 @@ impl ReplayWindow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod wire_equivalence_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{FecConfig, FecScheme, ProtocolPhase};
|
||||||
|
use crate::transport::loopback_pair;
|
||||||
|
|
||||||
|
fn host_cfg(scheme: FecScheme, fec_percent: u8, encrypt: bool) -> Config {
|
||||||
|
Config {
|
||||||
|
role: Role::Host,
|
||||||
|
phase: match scheme {
|
||||||
|
FecScheme::Gf8 => ProtocolPhase::P1GameStream,
|
||||||
|
FecScheme::Gf16 => ProtocolPhase::P2Punktfunk,
|
||||||
|
},
|
||||||
|
fec: FecConfig {
|
||||||
|
scheme,
|
||||||
|
fec_percent,
|
||||||
|
max_data_per_block: 8,
|
||||||
|
},
|
||||||
|
shard_payload: 64,
|
||||||
|
max_frame_bytes: 8 * 1024 * 1024,
|
||||||
|
encrypt,
|
||||||
|
key: [7u8; 16],
|
||||||
|
salt: [3, 1, 4, 1],
|
||||||
|
loopback_drop_period: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_session(cfg: Config) -> Session {
|
||||||
|
let (h, _c) = loopback_pair(0, 0);
|
||||||
|
Session::new(cfg, Box::new(h)).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reference wire path: build owned packets via the `packetize` wrapper, then seal
|
||||||
|
/// each into its own buffer — the pre-zero-copy implementation of `seal_frame`, spelled
|
||||||
|
/// out with the session's own private pieces so the two paths share nothing but state.
|
||||||
|
fn seal_via_wrapper(sess: &mut Session, frame: &[u8], pts_ns: u64, flags: u32) -> Vec<Vec<u8>> {
|
||||||
|
let packets = sess
|
||||||
|
.packetizer
|
||||||
|
.packetize(frame, pts_ns, flags, sess.coder.as_ref())
|
||||||
|
.unwrap();
|
||||||
|
let mut wires = Vec::new();
|
||||||
|
for pkt in &packets {
|
||||||
|
let mut wire = Vec::new();
|
||||||
|
sess.seal_into(pkt, &mut wire).unwrap();
|
||||||
|
wires.push(wire);
|
||||||
|
}
|
||||||
|
wires
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `seal_frame`'s packetize-straight-into-the-wire-pool path must produce byte-identical
|
||||||
|
/// sealed output to the wrapper path (same plaintext = header ++ shard, same nonce
|
||||||
|
/// sequence) — for multi-block frames, partial tail shards, exact-multiple frames, the
|
||||||
|
/// empty frame, fec 0%/50%, both schemes, crypto on and off (plan §1.4).
|
||||||
|
#[test]
|
||||||
|
fn zero_copy_seal_matches_wrapper_path() {
|
||||||
|
for scheme in [FecScheme::Gf8, FecScheme::Gf16] {
|
||||||
|
for fec_percent in [0u8, 50] {
|
||||||
|
for encrypt in [true, false] {
|
||||||
|
let mut opt = host_session(host_cfg(scheme, fec_percent, encrypt));
|
||||||
|
let mut refr = host_session(host_cfg(scheme, fec_percent, encrypt));
|
||||||
|
|
||||||
|
// shard_payload 64 × max_data_per_block 8: >512 bytes spans FEC blocks.
|
||||||
|
let frames: Vec<Vec<u8>> = vec![
|
||||||
|
pattern(3000), // multi-block + partial tail shard
|
||||||
|
pattern(1024), // exact multiple (2 full blocks)
|
||||||
|
pattern(100), // single block, partial tail
|
||||||
|
Vec::new(), // empty frame → 1 zeroed shard
|
||||||
|
pattern(64), // exactly one full shard
|
||||||
|
];
|
||||||
|
for (i, frame) in frames.iter().enumerate() {
|
||||||
|
let got = opt.seal_frame(frame, 1000 * i as u64, i as u32).unwrap();
|
||||||
|
let want = seal_via_wrapper(&mut refr, frame, 1000 * i as u64, i as u32);
|
||||||
|
assert_eq!(
|
||||||
|
got, want,
|
||||||
|
"wire mismatch: scheme={scheme:?} fec={fec_percent}% encrypt={encrypt} frame#{i}"
|
||||||
|
);
|
||||||
|
// Return the buffers so later frames exercise the pooled-reuse path
|
||||||
|
// (including a bigger frame after a smaller one and vice versa).
|
||||||
|
opt.reclaim_wires(got);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pattern(len: usize) -> Vec<u8> {
|
||||||
|
(0..len).map(|i| (i * 31 + 7) as u8).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod replay_tests {
|
mod replay_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
|
|
||||||
mod loopback;
|
mod loopback;
|
||||||
mod qos;
|
mod qos;
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod qos_windows;
|
||||||
mod udp;
|
mod udp;
|
||||||
|
|
||||||
pub use loopback::{loopback_pair, LoopbackTransport};
|
pub use loopback::{loopback_pair, LoopbackTransport};
|
||||||
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass};
|
pub use qos::{grow_socket_buffers, set_dscp_default, set_media_qos, MediaClass, QosFlow};
|
||||||
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
|
/// Windows-only: reusable USO (UDP Send Offload) batch send for callers that own their own connected
|
||||||
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
/// socket (the GameStream video sender) rather than going through [`UdpTransport`].
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
|
//! QoS-aware path (Wi-Fi WMM access categories, a managed switch, a shaped uplink) can prioritize it
|
||||||
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
|
//! over bulk flows. Mirrors what Apollo/Sunshine tag — DSCP **CS5** for video, **CS6** for audio. It
|
||||||
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
|
//! is **opt-in** (`PUNKTFUNK_DSCP=1`, or [`set_dscp_default`] from an embedder — the Android client
|
||||||
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer ISPs/routers, and on
|
//! ties it to its experimental low-latency mode): DSCP can interact badly with some consumer
|
||||||
//! Windows a plain `IP_TOS` is silently stripped unless a qWAVE policy is active (Apollo uses the
|
//! ISPs/routers. On Windows a plain `IP_TOS` is silently stripped from the wire, so the marking
|
||||||
//! qWAVE API there — that port is a follow-up; today this is a no-op on the wire on Windows).
|
//! goes through qWAVE flows instead (see [`super::qos_windows`]) — the caller holds the returned
|
||||||
|
//! [`QosFlow`] guard for as long as the socket sends media.
|
||||||
|
|
||||||
use std::net::UdpSocket;
|
use std::net::UdpSocket;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -60,7 +61,7 @@ pub enum MediaClass {
|
|||||||
|
|
||||||
impl MediaClass {
|
impl MediaClass {
|
||||||
/// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte).
|
/// DSCP code point (the high 6 bits of the IPv4 TOS / IPv6 traffic-class byte).
|
||||||
const fn dscp(self) -> u32 {
|
pub(super) const fn dscp(self) -> u32 {
|
||||||
match self {
|
match self {
|
||||||
MediaClass::Video => 40, // CS5
|
MediaClass::Video => 40, // CS5
|
||||||
MediaClass::Audio => 48, // CS6
|
MediaClass::Audio => 48, // CS6
|
||||||
@@ -92,20 +93,43 @@ pub(crate) fn dscp_enabled() -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// RAII token for a socket's QoS marking. On Windows it is the qWAVE flow membership
|
||||||
|
/// ([`super::qos_windows::QosFlow`]) — dropping it removes the marking, so hold it for as long
|
||||||
|
/// as the socket sends media. Elsewhere DSCP rides the socket option itself and the token is
|
||||||
|
/// inert (and never constructed — [`set_media_qos`] returns `None`).
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub use super::qos_windows::QosFlow;
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub struct QosFlow {
|
||||||
|
_never: std::convert::Infallible,
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
|
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
|
||||||
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
|
/// unless `PUNKTFUNK_DSCP=1`. Every step is best-effort (failures logged at debug, never fatal) — QoS
|
||||||
/// is a nicety, not required for correctness.
|
/// is a nicety, not required for correctness.
|
||||||
///
|
///
|
||||||
/// IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't tagged. On Windows
|
/// The socket must already be `connect`ed (Windows derives the qWAVE flow from the connected
|
||||||
/// the `IP_TOS` set succeeds but the OS doesn't tag the wire without a qWAVE policy (follow-up).
|
/// 5-tuple). IPv4 only (all current media sockets bind `0.0.0.0`); a v6 socket simply isn't
|
||||||
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) {
|
/// tagged. Returns the [`QosFlow`] guard on Windows — keep it alive with the socket; `None`
|
||||||
if dscp_enabled() {
|
/// elsewhere (the marking is a plain socket option) and whenever a step refused.
|
||||||
|
pub fn set_media_qos(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
|
||||||
|
if !dscp_enabled() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
super::qos_windows::add_media_flow(socket, class)
|
||||||
|
}
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
apply_media_qos(socket, class);
|
apply_media_qos(socket, class);
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable
|
/// The unconditional QoS application, factored out of [`set_media_qos`] so it is directly testable
|
||||||
/// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues).
|
/// without touching the process-global `PUNKTFUNK_DSCP` env. Best-effort (every step logs-and-continues).
|
||||||
|
#[cfg_attr(windows, allow(dead_code))]
|
||||||
fn apply_media_qos(socket: &UdpSocket, class: MediaClass) {
|
fn apply_media_qos(socket: &UdpSocket, class: MediaClass) {
|
||||||
let sock = socket2::SockRef::from(socket);
|
let sock = socket2::SockRef::from(socket);
|
||||||
// DSCP occupies the high 6 bits of the TOS byte → shift left 2.
|
// DSCP occupies the high 6 bits of the TOS byte → shift left 2.
|
||||||
@@ -143,8 +167,8 @@ mod tests {
|
|||||||
fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
|
fn qos_and_buffer_growth_are_best_effort_and_never_panic() {
|
||||||
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
let sock = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||||
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
|
// No PUNKTFUNK_DSCP in the test env → early return; must not panic regardless.
|
||||||
set_media_qos(&sock, MediaClass::Video);
|
assert!(set_media_qos(&sock, MediaClass::Video).is_none());
|
||||||
set_media_qos(&sock, MediaClass::Audio);
|
assert!(set_media_qos(&sock, MediaClass::Audio).is_none());
|
||||||
grow_socket_buffers(&sock);
|
grow_socket_buffers(&sock);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//! qWAVE (qos2.h) DSCP marking — the Windows path of [`super::qos::set_media_qos`].
|
||||||
|
//!
|
||||||
|
//! On Windows a plain `IP_TOS` setsockopt succeeds but the stack strips the mark from the wire:
|
||||||
|
//! marking requires membership in a qWAVE flow, which is how Apollo/Sunshine tag (qwave.dll).
|
||||||
|
//! [`QOSAddSocketToFlow`] with a traffic type yields the OS default marking (AudioVideo → DSCP
|
||||||
|
//! 40, Voice → 56, both WMM-mapped); the follow-up `QOSSetFlow(QOSSetOutgoingDSCPValue)` pins
|
||||||
|
//! the exact CS5/CS6 code points the other platforms mark. The pin needs an elevated process or
|
||||||
|
//! the "allow non-admin DSCP" group policy and silently keeps the traffic-type default
|
||||||
|
//! otherwise — the host runs as the SYSTEM service (`PunktfunkHost`), so the exact-DSCP path
|
||||||
|
//! applies exactly where it matters (the video egress); user-mode clients keep traffic-type
|
||||||
|
//! defaults (still WMM-useful).
|
||||||
|
//!
|
||||||
|
//! Same contract as the rest of [`super::qos`]: opt-in (`dscp_enabled`), and every step
|
||||||
|
//! debug-logs and continues — QoS is a nicety, never required for correctness.
|
||||||
|
|
||||||
|
use super::qos::MediaClass;
|
||||||
|
use std::net::UdpSocket;
|
||||||
|
use std::os::windows::io::AsRawSocket;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use windows_sys::Win32::Foundation::{GetLastError, HANDLE};
|
||||||
|
use windows_sys::Win32::NetworkManagement::QoS::{
|
||||||
|
QOSAddSocketToFlow, QOSCreateHandle, QOSRemoveSocketFromFlow, QOSSetFlow,
|
||||||
|
QOSSetOutgoingDSCPValue, QOSTrafficTypeAudioVideo, QOSTrafficTypeVoice, QOS_NON_ADAPTIVE_FLOW,
|
||||||
|
QOS_VERSION,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The process-wide qWAVE handle (`QOSCreateHandle` once, cached). `None` = qWAVE unavailable
|
||||||
|
/// (e.g. the QWAVE service is disabled) — every flow request then no-ops. Deliberately never
|
||||||
|
/// closed: it lives as long as the process, like the media sockets whose flows it carries.
|
||||||
|
fn qos_handle() -> Option<HANDLE> {
|
||||||
|
static SLOT: OnceLock<Option<usize>> = OnceLock::new();
|
||||||
|
SLOT.get_or_init(|| {
|
||||||
|
let version = QOS_VERSION {
|
||||||
|
MajorVersion: 1,
|
||||||
|
MinorVersion: 0,
|
||||||
|
};
|
||||||
|
let mut handle: HANDLE = std::ptr::null_mut();
|
||||||
|
// SAFETY: both pointers are valid for the duration of the synchronous call.
|
||||||
|
if unsafe { QOSCreateHandle(&version, &mut handle) } == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
err = unsafe { GetLastError() },
|
||||||
|
"QOSCreateHandle failed — qWAVE DSCP marking unavailable"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(handle as usize)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|h| h as HANDLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RAII qWAVE flow membership: while held, the socket's egress carries the flow's marking;
|
||||||
|
/// dropping removes the socket from the flow. (Closing the socket also removes it implicitly —
|
||||||
|
/// the guard makes teardown explicit and ordered, and must outlive the socket's traffic.)
|
||||||
|
pub struct QosFlow {
|
||||||
|
/// Raw `SOCKET` value only — never dereferenced; qWAVE tolerates an already-closed socket
|
||||||
|
/// on remove (the error is deliberately ignored).
|
||||||
|
socket: u64,
|
||||||
|
flow_id: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for QosFlow {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(handle) = qos_handle() {
|
||||||
|
// SAFETY: handle/flow_id came from the successful add; a stale socket just errors.
|
||||||
|
unsafe { QOSRemoveSocketFromFlow(handle, self.socket as _, self.flow_id, 0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a **connected** media socket on a qWAVE flow of its class (video →
|
||||||
|
/// `QOSTrafficTypeAudioVideo`, audio → `QOSTrafficTypeVoice`), then best-effort pin the exact
|
||||||
|
/// DSCP the other platforms mark (CS5 = 40 / CS6 = 48). Returns the flow guard, or `None` when
|
||||||
|
/// a required step refused (logged at debug).
|
||||||
|
pub(super) fn add_media_flow(socket: &UdpSocket, class: MediaClass) -> Option<QosFlow> {
|
||||||
|
let handle = qos_handle()?;
|
||||||
|
let traffic_type = match class {
|
||||||
|
MediaClass::Video => QOSTrafficTypeAudioVideo,
|
||||||
|
MediaClass::Audio => QOSTrafficTypeVoice,
|
||||||
|
};
|
||||||
|
let raw = socket.as_raw_socket();
|
||||||
|
let mut flow_id = 0u32;
|
||||||
|
// NULL destination = derive the flow's 5-tuple from the connected socket (every media
|
||||||
|
// socket is `connect`ed before it is tagged).
|
||||||
|
// SAFETY: the socket is live for the call; `flow_id` is a valid out-pointer.
|
||||||
|
let ok = unsafe {
|
||||||
|
QOSAddSocketToFlow(
|
||||||
|
handle,
|
||||||
|
raw as _,
|
||||||
|
std::ptr::null(),
|
||||||
|
traffic_type,
|
||||||
|
QOS_NON_ADAPTIVE_FLOW,
|
||||||
|
&mut flow_id,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
err = unsafe { GetLastError() },
|
||||||
|
?class,
|
||||||
|
"QOSAddSocketToFlow failed — DSCP marking skipped"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Construct the guard FIRST so an early return below still removes the flow membership.
|
||||||
|
// (`raw` is already `u64` — std's `RawSocket` — so no cast; win64 clippy denies same-type casts.)
|
||||||
|
let flow = QosFlow {
|
||||||
|
socket: raw,
|
||||||
|
flow_id,
|
||||||
|
};
|
||||||
|
// Pin the exact code point. Succeeds for elevated processes or under the "allow non-admin
|
||||||
|
// DSCP" policy; otherwise the traffic-type default marking stands (40 / 56 — WMM-useful).
|
||||||
|
let dscp: u32 = class.dscp();
|
||||||
|
// SAFETY: `buffer` points at 4 valid bytes for the synchronous (no OVERLAPPED) call.
|
||||||
|
let ok = unsafe {
|
||||||
|
QOSSetFlow(
|
||||||
|
handle,
|
||||||
|
flow_id,
|
||||||
|
QOSSetOutgoingDSCPValue,
|
||||||
|
4,
|
||||||
|
&dscp as *const u32 as *const _,
|
||||||
|
0,
|
||||||
|
std::ptr::null_mut(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
tracing::debug!(
|
||||||
|
err = unsafe { GetLastError() },
|
||||||
|
?class,
|
||||||
|
"QOSSetFlow(OutgoingDSCPValue) refused — traffic-type default marking stands"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(?class, dscp, flow_id, "qWAVE flow pinned to exact DSCP");
|
||||||
|
}
|
||||||
|
Some(flow)
|
||||||
|
}
|
||||||
@@ -446,6 +446,9 @@ pub fn spawn_data_punch(sock: UdpSocket, stop: std::sync::Arc<std::sync::atomic:
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct UdpTransport {
|
pub struct UdpTransport {
|
||||||
|
/// qWAVE flow guard (Windows, opt-in DSCP): declared before `socket` so drop order removes
|
||||||
|
/// the flow membership before the socket closes. Always `None` off-Windows.
|
||||||
|
_qos_flow: Option<super::qos::QosFlow>,
|
||||||
socket: UdpSocket,
|
socket: UdpSocket,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,10 +467,14 @@ impl UdpTransport {
|
|||||||
socket.connect(peer)?;
|
socket.connect(peer)?;
|
||||||
super::qos::grow_socket_buffers(&socket);
|
super::qos::grow_socket_buffers(&socket);
|
||||||
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
// The native data plane is video-dominant — tag it as the video class (opt-in via
|
||||||
// PUNKTFUNK_DSCP). Each end marks its own egress.
|
// PUNKTFUNK_DSCP). Each end marks its own egress; the socket is connected by now, as
|
||||||
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
// the Windows qWAVE flow requires.
|
||||||
|
let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
||||||
socket.set_nonblocking(true)?;
|
socket.set_nonblocking(true)?;
|
||||||
Ok(UdpTransport { socket })
|
Ok(UdpTransport {
|
||||||
|
_qos_flow: qos_flow,
|
||||||
|
socket,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN
|
/// Host side of the data plane for clients that may sit behind NAT / a stateful inter-VLAN
|
||||||
@@ -524,9 +531,15 @@ impl UdpTransport {
|
|||||||
socket.connect(target.as_deref().unwrap_or(fallback_peer))?;
|
socket.connect(target.as_deref().unwrap_or(fallback_peer))?;
|
||||||
socket.set_read_timeout(None)?;
|
socket.set_read_timeout(None)?;
|
||||||
super::qos::grow_socket_buffers(&socket);
|
super::qos::grow_socket_buffers(&socket);
|
||||||
super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
let qos_flow = super::qos::set_media_qos(&socket, super::qos::MediaClass::Video);
|
||||||
socket.set_nonblocking(true)?;
|
socket.set_nonblocking(true)?;
|
||||||
Ok((UdpTransport { socket }, punched))
|
Ok((
|
||||||
|
UdpTransport {
|
||||||
|
_qos_flow: qos_flow,
|
||||||
|
socket,
|
||||||
|
},
|
||||||
|
punched,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`])
|
/// A second handle to the data socket, for sending hole-punch keepalives ([`PUNCH_MAGIC`])
|
||||||
|
|||||||
@@ -205,7 +205,8 @@ proptest! {
|
|||||||
let data: Vec<Vec<u8>> = (0..k)
|
let data: Vec<Vec<u8>> = (0..k)
|
||||||
.map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect())
|
.map(|i| (0..shard_len).map(|b| (i ^ b).wrapping_add(seed as usize) as u8).collect())
|
||||||
.collect();
|
.collect();
|
||||||
let recovery = coder.encode(&data, m).unwrap();
|
let refs: Vec<&[u8]> = data.iter().map(|s| s.as_slice()).collect();
|
||||||
|
let recovery = coder.encode(&refs, m).unwrap();
|
||||||
|
|
||||||
let mut received: Vec<Option<Vec<u8>>> =
|
let mut received: Vec<Option<Vec<u8>>> =
|
||||||
data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect();
|
data.iter().cloned().map(Some).chain(recovery.into_iter().map(Some)).collect();
|
||||||
|
|||||||
@@ -62,10 +62,13 @@ pub struct OutputFormat {
|
|||||||
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
|
/// HDR: the capturer converts to 10-bit (IDD-push FP16 → `P010`, or `Rgb10a2` for a 4:4:4 source).
|
||||||
/// `false` = 8-bit SDR.
|
/// `false` = 8-bit SDR.
|
||||||
pub hdr: bool,
|
pub hdr: bool,
|
||||||
/// Full-chroma 4:4:4 session: the capturer must keep full chroma — deliver packed **RGB**
|
/// Full-chroma 4:4:4 session: the capturer must keep full chroma. On Windows the IDD-push
|
||||||
/// (`Bgra` / `Rgb10a2`), NOT the subsampled `Nv12`/`P010` the Windows video-engine path produces by
|
/// capturer hands the **BGRA** slot through (skipping the subsampling BGRA→NV12
|
||||||
/// default — because 4:4:4 can only be recovered from a full-chroma source. NVENC then does the
|
/// VideoConverter) so NVENC ingests full-chroma RGB and CSCs to 4:4:4 itself — measured
|
||||||
/// RGB→YUV444 CSC at encode (chroma_format_idc=3). `false` on every 4:2:0 session.
|
/// on-glass (RTX 5070 Ti): ARGB + `chromaFormatIDC=3` yields TRUE 4:4:4 and the conversion
|
||||||
|
/// follows the configured VUI matrix (BT.709 limited since the VUI is always written). On
|
||||||
|
/// Linux it forces the CPU RGB path the encoder swscales to `YUV444P`. `false` on every
|
||||||
|
/// 4:2:0 session.
|
||||||
pub chroma_444: bool,
|
pub chroma_444: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,10 +407,11 @@ pub fn capture_virtual_output(
|
|||||||
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
|
// Duplication, no WGC helper). A FRESH monitor + ring is created per session: a REUSED monitor's
|
||||||
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
|
// swap-chain dies after ~2 sessions and can't be revived. The ring is always FP16 when the display
|
||||||
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
|
// is HDR (the driver composes the IDD in FP16); `want.hdr` proactively enables advanced color and
|
||||||
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12). `IddPushCapturer` takes the
|
// selects the per-frame conversion (FP16 → P010 vs BGRA → NV12, or BGRA → AYUV for a
|
||||||
// keepalive (it owns the virtual display). There is NO fallback (DDA + the WGC relay were removed):
|
// `want.chroma_444` SDR session). `IddPushCapturer` takes the keepalive (it owns the virtual
|
||||||
// if it can't open or the driver doesn't attach, the session fails cleanly and the client reconnects.
|
// display). There is NO fallback (DDA + the WGC relay were removed): if it can't open or the
|
||||||
idd_push::IddPushCapturer::open(target, pref, want.hdr, keep)
|
// driver doesn't attach, the session fails cleanly and the client reconnects.
|
||||||
|
idd_push::IddPushCapturer::open(target, pref, want.hdr, want.chroma_444, keep)
|
||||||
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
.map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||||
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
.map_err(|(e, _keep)| e.context("IDD-push capture open (no fallback)"))
|
||||||
}
|
}
|
||||||
@@ -422,9 +426,14 @@ pub(crate) fn capturer_supports_444() -> bool {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub(crate) fn capturer_supports_444() -> bool {
|
pub(crate) fn capturer_supports_444() -> bool {
|
||||||
// IDD-push 4:4:4 (full-chroma RGB from the FP16 ring) is the next step; until then the sole Windows
|
// IDD-push delivers full-chroma BGRA for an SDR 4:4:4 session (skipping the NV12
|
||||||
// capturer delivers subsampled NV12/P010 only, so the host honestly negotiates 4:2:0.
|
// VideoConverter) — but only the direct-NVENC backend ingests RGB and CSCs it to 4:4:4
|
||||||
false
|
// (measured on-glass: true full chroma, matrix follows the configured VUI), so gate on it
|
||||||
|
// (AMF can't 4:4:4 at all; the QSV/ffmpeg path has no RGB-input 4:4:4 wiring). An HDR
|
||||||
|
// display can't be known here (the virtual display's mode settles after the Welcome); that
|
||||||
|
// combination downgrades at capture time — the capturer emits P010 and the encoder's caps
|
||||||
|
// cross-check reports the 4:2:0 truth (the in-band SPS keeps the client correct either way).
|
||||||
|
crate::encode::windows_resolved_backend() == crate::encode::WindowsBackend::Nvenc
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
||||||
pub(crate) fn capturer_supports_444() -> bool {
|
pub(crate) fn capturer_supports_444() -> bool {
|
||||||
|
|||||||
@@ -464,21 +464,25 @@ float main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
|||||||
}
|
}
|
||||||
";
|
";
|
||||||
|
|
||||||
/// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV). Averages
|
/// P010 CHROMA pass PS — half-res, writes interleaved (Cb,Cr) to plane 1 (R16G16_UNORM RTV).
|
||||||
/// the 2x2 scRGB source footprint of this chroma sample (box filter) IN scRGB-linear space before the
|
/// **Left-cosited** (H.273 chroma_loc type 0 — the default every decoder infers when
|
||||||
/// PQ encode, then forms Cb/Cr from the averaged-then-PQ-encoded RGB. `inv_src` = (1/srcW, 1/srcH).
|
/// chroma_loc_info is unsignaled, and what the clients' sampling corrections assume): the chroma
|
||||||
|
/// sample sits ON the even luma column, vertically centered between its two rows — so the filter
|
||||||
|
/// is the 2-row average of that ONE column, IN scRGB-linear space before the PQ encode, then
|
||||||
|
/// Cb/Cr from the averaged-then-PQ-encoded RGB. (The old 2×2 box was CENTER-sited — a
|
||||||
|
/// half-luma-pixel chroma shift against what decoders reconstruct; the narrow column decimation
|
||||||
|
/// also keeps desktop text/edge chroma crisp, and block-uniform inputs stay exact for
|
||||||
|
/// `hdr_p010_selftest`.) `inv_src` = (1/srcW, 1/srcH).
|
||||||
const HDR_P010_UV_PS: &str = r"
|
const HDR_P010_UV_PS: &str = r"
|
||||||
#include_common
|
#include_common
|
||||||
cbuffer C : register(b0) { float2 inv_src; float2 pad; };
|
cbuffer C : register(b0) { float2 inv_src; float2 pad; };
|
||||||
float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||||
// `uv` is the chroma-sample centre in [0,1]; the 4 co-sited luma texels sit at uv ± half a luma
|
// `uv` is the chroma RT texel centre = the middle of the 2x2 luma block; the left-cosited
|
||||||
// texel in each axis. Average their scRGB (linear) values, then run the SAME PQ/CSC as the Y pass.
|
// target is the block's LEFT column, whose two texel centres sit at uv + (-h.x, ±h.y).
|
||||||
float2 h = inv_src * 0.5;
|
float2 h = inv_src * 0.5;
|
||||||
float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0);
|
float3 a = max(tx.Sample(sm, uv + float2(-h.x, -h.y)).rgb, 0.0);
|
||||||
float3 b = max(tx.Sample(sm, uv + float2( h.x, -h.y)).rgb, 0.0);
|
float3 b = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0);
|
||||||
float3 c = max(tx.Sample(sm, uv + float2(-h.x, h.y)).rgb, 0.0);
|
float3 scrgb = (a + b) * 0.5;
|
||||||
float3 d = max(tx.Sample(sm, uv + float2( h.x, h.y)).rgb, 0.0);
|
|
||||||
float3 scrgb = (a + b + c + d) * 0.25;
|
|
||||||
float3 nits = scrgb * 80.0;
|
float3 nits = scrgb * 80.0;
|
||||||
float3 lin2020 = mul(BT709_TO_BT2020, nits);
|
float3 lin2020 = mul(BT709_TO_BT2020, nits);
|
||||||
float3 pq = pq_oetf(lin2020 / 10000.0);
|
float3 pq = pq_oetf(lin2020 / 10000.0);
|
||||||
|
|||||||
@@ -565,6 +565,81 @@ impl Drop for DescriptorPoller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the
|
||||||
|
/// desktop was actively composing right beforehand (see [`StallWatch`]).
|
||||||
|
struct Stall {
|
||||||
|
/// How long the hole lasted (last fresh frame → the frame that ended it).
|
||||||
|
gap: Duration,
|
||||||
|
/// `Some(mean period)` when this stall completes a metronomic cycle (see
|
||||||
|
/// [`crate::metronome::Metronome`]).
|
||||||
|
metronomic: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive
|
||||||
|
/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present
|
||||||
|
/// path BELOW capture and only while no physical output is active).
|
||||||
|
///
|
||||||
|
/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a
|
||||||
|
/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within
|
||||||
|
/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a
|
||||||
|
/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose
|
||||||
|
/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing"
|
||||||
|
/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested
|
||||||
|
/// below; the caller does the logging.
|
||||||
|
struct StallWatch {
|
||||||
|
/// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate).
|
||||||
|
recent: std::collections::VecDeque<Instant>,
|
||||||
|
cadence: crate::metronome::Metronome,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StallWatch {
|
||||||
|
/// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus
|
||||||
|
/// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed.
|
||||||
|
const RECENT: usize = 8;
|
||||||
|
/// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow —
|
||||||
|
/// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage).
|
||||||
|
const ACTIVE_SPAN: Duration = Duration::from_millis(400);
|
||||||
|
/// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the
|
||||||
|
/// reported 300–700 ms freezes, above encode/present jitter.
|
||||||
|
const STALL_MIN: Duration = Duration::from_millis(150);
|
||||||
|
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1),
|
||||||
|
cadence: crate::metronome::Metronome::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without
|
||||||
|
/// the reset the first post-recreate frame would read as one).
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.recent.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall.
|
||||||
|
fn note_fresh(&mut self, now: Instant) -> Option<Stall> {
|
||||||
|
let was_active = self.recent.len() == Self::RECENT
|
||||||
|
&& self
|
||||||
|
.recent
|
||||||
|
.back()
|
||||||
|
.zip(self.recent.front())
|
||||||
|
.is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN);
|
||||||
|
let gap = self.recent.back().map(|last| now.duration_since(*last));
|
||||||
|
self.recent.push_back(now);
|
||||||
|
if self.recent.len() > Self::RECENT {
|
||||||
|
self.recent.pop_front();
|
||||||
|
}
|
||||||
|
let gap = gap?;
|
||||||
|
if !was_active || gap < Self::STALL_MIN {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Stall {
|
||||||
|
gap,
|
||||||
|
metronomic: self.cadence.note(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct IddPushCapturer {
|
pub struct IddPushCapturer {
|
||||||
device: ID3D11Device,
|
device: ID3D11Device,
|
||||||
context: ID3D11DeviceContext,
|
context: ID3D11DeviceContext,
|
||||||
@@ -594,6 +669,13 @@ pub struct IddPushCapturer {
|
|||||||
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
|
/// Windows mid-session. Drives the ring format (HDR → FP16 surfaces, SDR → BGRA) and the conversion.
|
||||||
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
|
/// Polled in the capture loop; a change recreates the ring (see [`Self::recreate_ring`]).
|
||||||
display_hdr: bool,
|
display_hdr: bool,
|
||||||
|
/// The session negotiated full-chroma 4:4:4: while the display is SDR the BGRA slot passes
|
||||||
|
/// THROUGH (a plain copy into the out ring, no NV12 VideoConverter) so NVENC gets full-chroma
|
||||||
|
/// RGB and CSCs to 4:4:4 itself — measured on-glass: `chromaFormatIDC=3` + ARGB input yields
|
||||||
|
/// TRUE 4:4:4 and the conversion follows the VUI matrix (BT.709 limited, always written).
|
||||||
|
/// While the display is HDR this is overridden to the P010 path (no 10-bit 4:4:4 source):
|
||||||
|
/// the stream honestly downgrades to 4:2:0 — the encoder's caps cross-check reports it.
|
||||||
|
want_444: bool,
|
||||||
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
|
/// Off-thread display-descriptor sampler (see [`DescriptorPoller`]) — the capture loop reads
|
||||||
/// its snapshot instead of running CCD queries inline on the frame path.
|
/// its snapshot instead of running CCD queries inline on the frame path.
|
||||||
desc_poller: DescriptorPoller,
|
desc_poller: DescriptorPoller,
|
||||||
@@ -615,6 +697,10 @@ pub struct IddPushCapturer {
|
|||||||
last_liveness: Instant,
|
last_liveness: Instant,
|
||||||
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
|
/// Rate-limits the mid-session [`kick_dwm_compose`] nudge (recovery window only).
|
||||||
last_kick: Instant,
|
last_kick: Instant,
|
||||||
|
/// Capture-stall watch (see [`StallWatch`]): flags multi-hundred-ms DWM composition holes
|
||||||
|
/// during active flow and warns when they turn metronomic — the sole-virtual-display
|
||||||
|
/// periodic-stutter diagnostic.
|
||||||
|
stall_watch: StallWatch,
|
||||||
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
/// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame
|
||||||
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
/// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the
|
||||||
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
/// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`:
|
||||||
@@ -745,9 +831,10 @@ impl IddPushCapturer {
|
|||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
keepalive: Box<dyn Send>,
|
keepalive: Box<dyn Send>,
|
||||||
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
) -> std::result::Result<Self, (anyhow::Error, Box<dyn Send>)> {
|
||||||
match Self::open_inner(target, preferred, client_10bit) {
|
match Self::open_inner(target, preferred, client_10bit, want_444) {
|
||||||
Ok(mut me) => {
|
Ok(mut me) => {
|
||||||
me._keepalive = keepalive;
|
me._keepalive = keepalive;
|
||||||
Ok(me)
|
Ok(me)
|
||||||
@@ -760,6 +847,7 @@ impl IddPushCapturer {
|
|||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
// The ring MUST live on the adapter the driver's swap-chain renders on. Primary: the
|
||||||
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
// selected render GPU — the same pick SET_RENDER_ADAPTER pinned the driver to at monitor
|
||||||
@@ -774,7 +862,7 @@ impl IddPushCapturer {
|
|||||||
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
LowPart: (target.adapter_luid & 0xffff_ffff) as u32,
|
||||||
HighPart: (target.adapter_luid >> 32) as i32,
|
HighPart: (target.adapter_luid >> 32) as i32,
|
||||||
});
|
});
|
||||||
match Self::open_on(target.clone(), preferred, client_10bit, luid) {
|
match Self::open_on(target.clone(), preferred, client_10bit, want_444, luid) {
|
||||||
Ok(me) => Ok(me),
|
Ok(me) => Ok(me),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
// Self-heal a render-adapter mismatch ONCE: on TEX_FAIL the driver has reported the
|
||||||
@@ -799,7 +887,7 @@ impl IddPushCapturer {
|
|||||||
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
"IDD push: ring/driver render-adapter mismatch — rebinding the ring to the \
|
||||||
driver's reported adapter"
|
driver's reported adapter"
|
||||||
);
|
);
|
||||||
Self::open_on(target, preferred, client_10bit, drv)
|
Self::open_on(target, preferred, client_10bit, want_444, drv)
|
||||||
.context("IDD-push rebind to the driver's reported render adapter")
|
.context("IDD-push rebind to the driver's reported render adapter")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -809,6 +897,7 @@ impl IddPushCapturer {
|
|||||||
target: WinCaptureTarget,
|
target: WinCaptureTarget,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
client_10bit: bool,
|
client_10bit: bool,
|
||||||
|
want_444: bool,
|
||||||
luid: LUID,
|
luid: LUID,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let (pw, ph, _hz) = preferred
|
let (pw, ph, _hz) = preferred
|
||||||
@@ -963,6 +1052,7 @@ impl IddPushCapturer {
|
|||||||
mode = format!("{w}x{h}"),
|
mode = format!("{w}x{h}"),
|
||||||
display_hdr,
|
display_hdr,
|
||||||
client_10bit,
|
client_10bit,
|
||||||
|
want_444,
|
||||||
ring_fp16 = display_hdr,
|
ring_fp16 = display_hdr,
|
||||||
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
"IDD push(host): created sealed ring + delivered the channel; waiting for the driver \
|
||||||
to attach + publish"
|
to attach + publish"
|
||||||
@@ -981,6 +1071,7 @@ impl IddPushCapturer {
|
|||||||
generation,
|
generation,
|
||||||
client_10bit,
|
client_10bit,
|
||||||
display_hdr,
|
display_hdr,
|
||||||
|
want_444,
|
||||||
desc_poller: DescriptorPoller::spawn(
|
desc_poller: DescriptorPoller::spawn(
|
||||||
target.target_id,
|
target.target_id,
|
||||||
DisplayDescriptor {
|
DisplayDescriptor {
|
||||||
@@ -995,6 +1086,7 @@ impl IddPushCapturer {
|
|||||||
last_fresh: Instant::now(),
|
last_fresh: Instant::now(),
|
||||||
last_liveness: Instant::now(),
|
last_liveness: Instant::now(),
|
||||||
last_kick: Instant::now(),
|
last_kick: Instant::now(),
|
||||||
|
stall_watch: StallWatch::new(),
|
||||||
out_ring: Vec::new(),
|
out_ring: Vec::new(),
|
||||||
out_idx: 0,
|
out_idx: 0,
|
||||||
video_conv: None,
|
video_conv: None,
|
||||||
@@ -1139,15 +1231,24 @@ impl IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven SOLELY by the DISPLAY's HDR
|
/// The output texture format + the [`PixelFormat`] NVENC encodes, driven by the DISPLAY's HDR
|
||||||
/// state (like the WGC path): HDR → `P010` (BT.2020 PQ 10-bit limited) → NVENC Main10, and the client
|
/// state (like the WGC path) plus the session's 4:4:4 negotiation: HDR → `P010` (BT.2020 PQ
|
||||||
/// auto-detects PQ from the HEVC VUI; SDR → `Nv12` (BT.709 8-bit limited). Both are native YUV so
|
/// 10-bit limited) → NVENC Main10, and the client auto-detects PQ from the HEVC VUI; SDR →
|
||||||
/// NVENC skips its internal RGB→YUV CSC on the contended SM (plan §5.A). We do NOT gate HDR on the
|
/// `Nv12` (BT.709 8-bit limited), or full-chroma `Bgra` passthrough on a 4:4:4 session (NVENC
|
||||||
/// client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the Mac advertises 10-bit
|
/// CSCs RGB→YUV444 itself, following the BT.709 VUI — the one path that deliberately pays the
|
||||||
/// only when its OWN display is HDR), yet all decode Main10 + auto-switch, exactly as on the WGC path.
|
/// SM-side CSC, because the video processor can only produce subsampled output). We do NOT
|
||||||
|
/// gate HDR on the client's advertised `VIDEO_CAP_10BIT` — clients under-report it (e.g. the
|
||||||
|
/// Mac advertises 10-bit only when its OWN display is HDR), yet all decode Main10 +
|
||||||
|
/// auto-switch, exactly as on the WGC path. HDR wins over 4:4:4 (there is no 10-bit
|
||||||
|
/// full-chroma source): the stream downgrades to 4:2:0 with a warning.
|
||||||
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
fn out_format(&self) -> (DXGI_FORMAT, PixelFormat) {
|
||||||
if self.display_hdr {
|
if self.display_hdr {
|
||||||
|
if self.want_444 {
|
||||||
|
warn_444_hdr_downgrade_once();
|
||||||
|
}
|
||||||
(DXGI_FORMAT_P010, PixelFormat::P010)
|
(DXGI_FORMAT_P010, PixelFormat::P010)
|
||||||
|
} else if self.want_444 {
|
||||||
|
(DXGI_FORMAT_B8G8R8A8_UNORM, PixelFormat::Bgra)
|
||||||
} else {
|
} else {
|
||||||
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
|
(DXGI_FORMAT_NV12, PixelFormat::Nv12)
|
||||||
}
|
}
|
||||||
@@ -1317,6 +1418,7 @@ impl IddPushCapturer {
|
|||||||
|
|
||||||
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
|
/// Build the per-mode YUV converter if not already built: a VIDEO-engine BGRA→NV12 processor on an
|
||||||
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
/// SDR display, or the FP16→P010 shader on an HDR display. Both keep NVENC's RGB→YUV CSC off the SM.
|
||||||
|
/// An SDR 4:4:4 session needs NO converter — the BGRA slot passes through (see `out_format`).
|
||||||
fn ensure_converter(&mut self) -> Result<()> {
|
fn ensure_converter(&mut self) -> Result<()> {
|
||||||
if self.display_hdr {
|
if self.display_hdr {
|
||||||
if self.hdr_p010_conv.is_none() {
|
if self.hdr_p010_conv.is_none() {
|
||||||
@@ -1325,6 +1427,8 @@ impl IddPushCapturer {
|
|||||||
// belong to, and `?` propagates any failure before the converter is stored.
|
// belong to, and `?` propagates any failure before the converter is stored.
|
||||||
self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? });
|
self.hdr_p010_conv = Some(unsafe { HdrP010Converter::new(&self.device)? });
|
||||||
}
|
}
|
||||||
|
} else if self.want_444 {
|
||||||
|
// Full-chroma passthrough — no conversion resources to build.
|
||||||
} else if self.video_conv.is_none() {
|
} else if self.video_conv.is_none() {
|
||||||
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
|
// SAFETY: `VideoConverter::new` is `unsafe` (it sets up the D3D11 VIDEO processor); we pass live
|
||||||
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
|
// borrows of `self.device` + its immediate `self.context` (single-threaded, this thread) plus
|
||||||
@@ -1429,6 +1533,11 @@ impl IddPushCapturer {
|
|||||||
self.height,
|
self.height,
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
} else if self.want_444 {
|
||||||
|
// SDR 4:4:4: pass the BGRA slot through untouched — NVENC ingests full-chroma
|
||||||
|
// RGB and CSCs to YUV 4:4:4 itself (per the always-written BT.709 VUI). Plain
|
||||||
|
// copy-engine move; the slot releases back to the driver immediately.
|
||||||
|
self.context.CopyResource(&out, &s.tex);
|
||||||
} else {
|
} else {
|
||||||
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
// SDR: BGRA slot → NV12 on the VIDEO engine; NVENC takes native NV12, no SM-side CSC.
|
||||||
if let Some(conv) = self.video_conv.as_ref() {
|
if let Some(conv) = self.video_conv.as_ref() {
|
||||||
@@ -1441,8 +1550,34 @@ impl IddPushCapturer {
|
|||||||
self.out_idx = (i + 1) % self.out_ring.len();
|
self.out_idx = (i + 1) % self.out_ring.len();
|
||||||
self.last_seq = seq;
|
self.last_seq = seq;
|
||||||
self.last_present = Some((out.clone(), pf));
|
self.last_present = Some((out.clone(), pf));
|
||||||
self.recovering_since = None; // a fresh frame resumed → recovered
|
let now = Instant::now();
|
||||||
self.last_fresh = Instant::now(); // feeds the driver-death watch
|
if self.recovering_since.take().is_some() {
|
||||||
|
// A fresh frame resumed → recovered. The recovery gap is self-inflicted (ring
|
||||||
|
// recreate, already logged by the recreate path) — reset the stall watch so it
|
||||||
|
// doesn't read as a DWM stall.
|
||||||
|
self.stall_watch.reset();
|
||||||
|
} else if let Some(stall) = self.stall_watch.note_fresh(now) {
|
||||||
|
// debug (not warn): a single hole also happens when content legitimately pauses;
|
||||||
|
// the reportable signal is the metronomic cycle below. Mounjay-class triage runs
|
||||||
|
// at debug level, and the web-console debug ring captures these.
|
||||||
|
tracing::debug!(
|
||||||
|
gap_ms = stall.gap.as_millis() as u64,
|
||||||
|
"IDD-push capture stall — the desktop was composing at speed, then DWM \
|
||||||
|
delivered no frame for the gap; the present path stalled below capture"
|
||||||
|
);
|
||||||
|
if let Some(period) = stall.metronomic {
|
||||||
|
tracing::warn!(
|
||||||
|
period_s = format!("{:.2}", period.as_secs_f64()),
|
||||||
|
"capture stalls are METRONOMIC — DWM stops composing the virtual display \
|
||||||
|
on a stable period, i.e. a periodic display-path disturbance BELOW \
|
||||||
|
capture (DWM present clock / GPU driver / display-poller software). \
|
||||||
|
Correlate with 'slow display-descriptor poll'; if that never fires, the \
|
||||||
|
disturbance is outside punktfunk — try display topology=primary or \
|
||||||
|
extend (keep a physical output active), or a different refresh rate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.last_fresh = now; // feeds the driver-death watch
|
||||||
Ok(Some(CapturedFrame {
|
Ok(Some(CapturedFrame {
|
||||||
width: self.width,
|
width: self.width,
|
||||||
height: self.height,
|
height: self.height,
|
||||||
@@ -1566,6 +1701,21 @@ impl Capturer for IddPushCapturer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16
|
||||||
|
/// desktop needs the PQ tone curve, which the P010 shader provides at 4:2:0), so the stream
|
||||||
|
/// honestly downgrades — the encoder's `chroma_444` caps cross-check reports it and the in-band
|
||||||
|
/// SPS keeps the client decoding correctly. Once per process: the state can flap mid-session.
|
||||||
|
fn warn_444_hdr_downgrade_once() {
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
static ONCE: AtomicBool = AtomicBool::new(true);
|
||||||
|
if ONCE.swap(false, Ordering::Relaxed) {
|
||||||
|
tracing::warn!(
|
||||||
|
"4:4:4 negotiated but the display is HDR — no 10-bit full-chroma source exists; \
|
||||||
|
encoding HDR 4:2:0 (P010) instead (disable HDR on the virtual display for 4:4:4)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Drop for IddPushCapturer {
|
impl Drop for IddPushCapturer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
self.slots.clear();
|
self.slots.clear();
|
||||||
@@ -1576,3 +1726,99 @@ impl Drop for IddPushCapturer {
|
|||||||
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
|
// `design/idd-push-security.md`). _keepalive drops after, REMOVEing the virtual display.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and
|
||||||
|
/// return what each `note_fresh` produced.
|
||||||
|
fn watch_run(offsets_ms: &[u64]) -> Vec<Option<Stall>> {
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut w = StallWatch::new();
|
||||||
|
offsets_ms
|
||||||
|
.iter()
|
||||||
|
.map(|ms| w.note_fresh(base + Duration::from_millis(*ms)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 60 fps flow (16 ms cadence) for `frames` frames starting at `start_ms`, appended to `out`.
|
||||||
|
fn flow(out: &mut Vec<u64>, start_ms: u64, frames: u64) {
|
||||||
|
out.extend((0..frames).map(|i| start_ms + i * 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_detected_after_active_flow() {
|
||||||
|
// 20 frames of 60 fps flow, then a 300 ms hole — the resuming frame reads as a stall.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
flow(&mut t, 0, 20); // last frame at 304 ms
|
||||||
|
t.push(604);
|
||||||
|
let out = watch_run(&t);
|
||||||
|
assert!(out[..20].iter().all(Option::is_none));
|
||||||
|
let stall = out[20].as_ref().expect("hole after active flow is a stall");
|
||||||
|
assert_eq!(stall.gap.as_millis(), 300);
|
||||||
|
assert!(stall.metronomic.is_none(), "one stall is not a cycle");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_desktop_gaps_are_not_stalls() {
|
||||||
|
// Caret-blink damage: frames ~530 ms apart — the activity gate never opens, so neither
|
||||||
|
// the blink gaps nor a long idle hole count.
|
||||||
|
let t: Vec<u64> = (0..12).map(|i| i * 530).chain([20_000]).collect();
|
||||||
|
assert!(watch_run(&t).iter().all(Option::is_none));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn thirty_fps_content_still_qualifies_as_active() {
|
||||||
|
// A 30 fps-capped game (33 ms cadence): 8 pre-gap frames span 231 ms ≤ ACTIVE_SPAN, so a
|
||||||
|
// 200 ms hole still reads as a stall.
|
||||||
|
let mut t: Vec<u64> = (0..10).map(|i| i * 33).collect(); // last at 297 ms
|
||||||
|
t.push(497);
|
||||||
|
let out = watch_run(&t);
|
||||||
|
assert!(out[10].is_some(), "30 fps flow must pass the activity gate");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metronomic_stalls_self_diagnose() {
|
||||||
|
// The field signature: ~300 ms DWM holes every 4 s inside 60 fps flow. Stalls land at the
|
||||||
|
// cycle BOUNDARIES (5 cycles → 4 stalls); the 4th completes the metronome streak and
|
||||||
|
// reports the ~4 s period.
|
||||||
|
let mut t = Vec::new();
|
||||||
|
for cycle in 0..5u64 {
|
||||||
|
// ~3.7 s of flow, then the hole to the next cycle start.
|
||||||
|
flow(&mut t, cycle * 4_000, 232); // last frame at cycle*4000 + 3696
|
||||||
|
}
|
||||||
|
let out = watch_run(&t);
|
||||||
|
let stalls: Vec<&Stall> = out.iter().flatten().collect();
|
||||||
|
assert_eq!(stalls.len(), 4, "each cycle boundary is one stall");
|
||||||
|
assert!(stalls[..3].iter().all(|s| s.metronomic.is_none()));
|
||||||
|
let period = stalls[3]
|
||||||
|
.metronomic
|
||||||
|
.expect("the 4th evenly-spaced event completes the metronome streak");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 4.0).abs() < 0.3,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_swallows_the_recreate_gap() {
|
||||||
|
// Active flow, then a ring recreate (reset), then flow resumes 800 ms later — the resume
|
||||||
|
// frame must NOT read as a stall, and detection re-arms afterwards.
|
||||||
|
let base = Instant::now();
|
||||||
|
let at = |ms: u64| base + Duration::from_millis(ms);
|
||||||
|
let mut w = StallWatch::new();
|
||||||
|
for i in 0..20u64 {
|
||||||
|
assert!(w.note_fresh(at(i * 16)).is_none());
|
||||||
|
}
|
||||||
|
w.reset();
|
||||||
|
assert!(w.note_fresh(at(1_104)).is_none(), "recreate gap swallowed");
|
||||||
|
for i in 1..20u64 {
|
||||||
|
assert!(w.note_fresh(at(1_104 + i * 16)).is_none());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
w.note_fresh(at(1_104 + 19 * 16 + 300)).is_some(),
|
||||||
|
"detection re-armed after the reset"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -326,11 +326,19 @@ impl NvencEncoder {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 *limited* range
|
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
|
||||||
// (swscale), so signal that in the bitstream VUI (colorspace/range/primaries/transfer) —
|
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
|
||||||
// otherwise the client decoder assumes a default and the picture comes out washed-out /
|
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
|
||||||
// wrong-contrast. The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes
|
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
|
||||||
// its own VUI). Matches the Windows NV12 path's BT.709 limited-range signalling.
|
// Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||||
|
//
|
||||||
|
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
|
||||||
|
// recovers the ~12% of code space limited-range quantization gives up, for the exact
|
||||||
|
// text/UI chroma 4:4:4 exists for. Every punktfunk client honors the signaled range
|
||||||
|
// (csc_rows / the Apple rows port); ship as default only if the on-glass A/B shows a
|
||||||
|
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
|
||||||
|
let full_range_444 =
|
||||||
|
want_444 && std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
|
||||||
if matches!(format, PixelFormat::Nv12) || want_444 {
|
if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||||
@@ -339,7 +347,11 @@ impl NvencEncoder {
|
|||||||
unsafe {
|
unsafe {
|
||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; // limited/studio
|
(*raw).color_range = if full_range_444 {
|
||||||
|
ffi::AVColorRange::AVCOL_RANGE_JPEG // full
|
||||||
|
} else {
|
||||||
|
ffi::AVColorRange::AVCOL_RANGE_MPEG // limited/studio
|
||||||
|
};
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||||
}
|
}
|
||||||
@@ -401,10 +413,12 @@ impl NvencEncoder {
|
|||||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
||||||
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
||||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
||||||
// CSC settings into `sws` (limited-range dst: dstRange = 0). No Rust memory is passed.
|
// CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||||
|
// PUNKTFUNK_444_FULLRANGE experiment). No Rust memory is passed.
|
||||||
unsafe {
|
unsafe {
|
||||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
let dst_range = i32::from(full_range_444);
|
||||||
|
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, dst_range, 0, 1 << 16, 1 << 16);
|
||||||
}
|
}
|
||||||
Some(sws)
|
Some(sws)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -204,8 +204,9 @@ unsafe fn open_vaapi_encoder_mode(
|
|||||||
let raw = video.as_mut_ptr();
|
let raw = video.as_mut_ptr();
|
||||||
(*raw).rc_buffer_size = vbv_bits as i32;
|
(*raw).rc_buffer_size = vbv_bits as i32;
|
||||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC, or scale_vaapi which preserves the
|
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||||
// input range we tag), so signal that VUI — else the client decoder washes the picture out.
|
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||||
|
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||||
@@ -718,6 +719,11 @@ impl DmabufInner {
|
|||||||
(*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
(*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||||
(*par).width = width as c_int;
|
(*par).width = width as c_int;
|
||||||
(*par).height = height as c_int;
|
(*par).height = height as c_int;
|
||||||
|
// Declare the link's colour up front (full-range RGB — the compositor's desktop) so
|
||||||
|
// the per-frame tags in `submit` match the negotiated link instead of reading as a
|
||||||
|
// mid-stream property change.
|
||||||
|
(*par).color_space = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||||
|
(*par).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||||
(*par).time_base = ffi::AVRational {
|
(*par).time_base = ffi::AVRational {
|
||||||
num: 1,
|
num: 1,
|
||||||
den: fps as c_int,
|
den: fps as c_int,
|
||||||
@@ -751,7 +757,14 @@ impl DmabufInner {
|
|||||||
}
|
}
|
||||||
init!(src, ptr::null(), "buffer");
|
init!(src, ptr::null(), "buffer");
|
||||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||||
init!(scale, c"format=nv12".as_ptr(), "scale_vaapi");
|
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited).
|
||||||
|
// Without the explicit options the conversion matrix is whatever the driver defaults
|
||||||
|
// to for an unspecified output (Mesa: BT.601) — a hue shift against the signaled VUI.
|
||||||
|
init!(
|
||||||
|
scale,
|
||||||
|
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||||
|
"scale_vaapi"
|
||||||
|
);
|
||||||
init!(sink, ptr::null(), "buffersink");
|
init!(sink, ptr::null(), "buffersink");
|
||||||
|
|
||||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||||
@@ -879,6 +892,12 @@ impl DmabufInner {
|
|||||||
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||||
(*drm).width = self.width as c_int;
|
(*drm).width = self.width as c_int;
|
||||||
(*drm).height = self.height as c_int;
|
(*drm).height = self.height as c_int;
|
||||||
|
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
|
||||||
|
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
|
||||||
|
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
|
||||||
|
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
|
||||||
|
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||||
|
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
|
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
|
||||||
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
||||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
//! Software H.264 encoder (openh264) — the GPU-less encode path for the Windows host (and a
|
//! Software H.264 encoder (openh264) — the GPU-less encode path for the Windows host (and a
|
||||||
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
|
//! fallback when NVENC is unavailable). Low-latency screen-content config: single-reference,
|
||||||
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR, BT.709 limited range.
|
//! no B-frames (Baseline), bitrate rate-control, in-band SPS/PPS each IDR.
|
||||||
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
//! Synchronous: `submit` encodes immediately and stashes the AU for `poll` (no internal queue).
|
||||||
|
//!
|
||||||
|
//! The RGB→YUV conversion is OURS, BT.709 limited range: openh264 writes no colour description
|
||||||
|
//! into the VUI (unspecified), so decoders fall back to their default — BT.709 limited on every
|
||||||
|
//! punktfunk client — and the pixels must match that default. The crate's own `YUVBuffer`
|
||||||
|
//! converter is BT.601 (0.2578/0.5039/0.0977 + 16), which decoded-as-709 is a constant hue
|
||||||
|
//! error; that's why it is NOT used here.
|
||||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||||
|
|
||||||
@@ -12,19 +18,20 @@ use openh264::encoder::{
|
|||||||
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
|
BitRate, Complexity, Encoder as Oh264, EncoderConfig, FrameRate, FrameType, IntraFramePeriod,
|
||||||
Profile, RateControlMode, SpsPpsStrategy, UsageType,
|
Profile, RateControlMode, SpsPpsStrategy, UsageType,
|
||||||
};
|
};
|
||||||
use openh264::formats::{BgraSliceU8, RgbSliceU8, YUVBuffer};
|
use openh264::formats::YUVSlices;
|
||||||
use openh264::OpenH264API;
|
use openh264::OpenH264API;
|
||||||
|
|
||||||
pub struct OpenH264Encoder {
|
pub struct OpenH264Encoder {
|
||||||
enc: Oh264,
|
enc: Oh264,
|
||||||
yuv: YUVBuffer,
|
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
fps: u32,
|
fps: u32,
|
||||||
src_format: PixelFormat,
|
src_format: PixelFormat,
|
||||||
/// BGRA scratch for the 3-bpp (Bgr) and R/B-swapped (Rgba/Rgbx) formats openh264 can't wrap
|
/// The converted I420 planes (our BT.709-limited CSC — see the module doc), reused across
|
||||||
/// directly. Reused across frames.
|
/// frames: full-res luma + quarter-res Cb/Cr, tightly packed (stride = width, width/2).
|
||||||
scratch: Vec<u8>,
|
y_plane: Vec<u8>,
|
||||||
|
u_plane: Vec<u8>,
|
||||||
|
v_plane: Vec<u8>,
|
||||||
frame_idx: i64,
|
frame_idx: i64,
|
||||||
force_kf: bool,
|
force_kf: bool,
|
||||||
/// At most one AU per submit (no lookahead), handed back by the next `poll`.
|
/// At most one AU per submit (no lookahead), handed back by the next `poll`.
|
||||||
@@ -33,7 +40,7 @@ pub struct OpenH264Encoder {
|
|||||||
|
|
||||||
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
// openh264's Encoder holds a raw C handle (not auto-Send); it lives on the single encode thread.
|
||||||
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
|
// SAFETY: `OpenH264Encoder` wraps `Oh264` (openh264's `Encoder`), which holds a raw C handle to the
|
||||||
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (`YUVBuffer`, `Vec`, scalars,
|
// openh264 `ISVCEncoder` and is not auto-`Send`; the other fields (the plane `Vec`s, scalars,
|
||||||
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
|
// `Option<EncodedFrame>`) are plain owned data. The session creates the encoder, calls
|
||||||
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
|
// `submit`/`poll`/`flush`, and drops it all on one dedicated encode thread, never sharing it by
|
||||||
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
|
// reference across threads, so the C handle is only ever touched from a single thread. Moving the
|
||||||
@@ -62,50 +69,74 @@ impl OpenH264Encoder {
|
|||||||
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
|
.scene_change_detect(false) // no surprise IDRs (bitrate spikes / freeze)
|
||||||
.adaptive_quantization(true)
|
.adaptive_quantization(true)
|
||||||
.complexity(Complexity::Low) // latency over BD-rate
|
.complexity(Complexity::Low) // latency over BD-rate
|
||||||
.profile(Profile::Baseline); // no B-frames; BT.709 limited is the crate default VUI
|
.profile(Profile::Baseline); // no B-frames; the VUI carries no colour description
|
||||||
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
|
let api = OpenH264API::from_source(); // statically-bundled build (default `source` feature)
|
||||||
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
|
let enc = Oh264::with_api_config(api, cfg).context("openh264 Encoder::with_api_config")?;
|
||||||
let yuv = YUVBuffer::new(width as usize, height as usize);
|
let (w, h) = (width as usize, height as usize);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"openh264 software encoder: {width}x{height}@{fps} {} Mbps (Baseline, screen-content)",
|
"openh264 software encoder: {width}x{height}@{fps} {} Mbps (Baseline, screen-content)",
|
||||||
bps / 1_000_000
|
bps / 1_000_000
|
||||||
);
|
);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
enc,
|
enc,
|
||||||
yuv,
|
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
src_format: format,
|
src_format: format,
|
||||||
scratch: Vec::new(),
|
y_plane: vec![0; w * h],
|
||||||
|
u_plane: vec![0; (w / 2) * (h / 2)],
|
||||||
|
v_plane: vec![0; (w / 2) * (h / 2)],
|
||||||
frame_idx: 0,
|
frame_idx: 0,
|
||||||
force_kf: false,
|
force_kf: false,
|
||||||
pending: None,
|
pending: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize a packed source buffer into the reused BGRA `scratch` ([B,G,R,A]). `rgb_order`
|
/// Convert one packed full-range RGB frame into the I420 planes, BT.709 limited range.
|
||||||
/// = source is R,G,B (swap into B,G,R); otherwise source is already B,G,R.
|
/// `bpp` is the source pixel stride; `ri`/`gi`/`bi` the channel byte offsets within a pixel.
|
||||||
fn normalize_to_bgra(&mut self, src: &[u8], src_bpp: usize, rgb_order: bool) {
|
/// Luma per pixel; Cb/Cr from the 2×2 block's averaged RGB (the same box filter the crate's
|
||||||
|
/// converter used, so only the matrix changed).
|
||||||
|
fn convert_bt709(&mut self, src: &[u8], bpp: usize, ri: usize, gi: usize, bi: usize) {
|
||||||
let w = self.width as usize;
|
let w = self.width as usize;
|
||||||
let h = self.height as usize;
|
let h = self.height as usize;
|
||||||
self.scratch.resize(w * h * 4, 0);
|
let cw = w / 2;
|
||||||
for px in 0..(w * h) {
|
for by in 0..h / 2 {
|
||||||
let s = &src[px * src_bpp..px * src_bpp + 3];
|
for bx in 0..cw {
|
||||||
let d = &mut self.scratch[px * 4..px * 4 + 4];
|
let mut sum = (0f32, 0f32, 0f32);
|
||||||
if rgb_order {
|
for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
|
||||||
d[0] = s[2];
|
let (px, py) = (bx * 2 + dx, by * 2 + dy);
|
||||||
d[1] = s[1];
|
let s = &src[(py * w + px) * bpp..];
|
||||||
d[2] = s[0];
|
let (r, g, b) = (f32::from(s[ri]), f32::from(s[gi]), f32::from(s[bi]));
|
||||||
} else {
|
self.y_plane[py * w + px] = luma709(r, g, b);
|
||||||
d[0] = s[0];
|
sum = (sum.0 + r, sum.1 + g, sum.2 + b);
|
||||||
d[1] = s[1];
|
|
||||||
d[2] = s[2];
|
|
||||||
}
|
}
|
||||||
d[3] = 0xff;
|
let (cb, cr) = chroma709(sum.0 / 4.0, sum.1 / 4.0, sum.2 / 4.0);
|
||||||
|
self.u_plane[by * cw + bx] = cb;
|
||||||
|
self.v_plane[by * cw + bx] = cr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BT.709 luma coefficients (Kg = 1 − Kr − Kb).
|
||||||
|
const KR: f32 = 0.2126;
|
||||||
|
const KB: f32 = 0.0722;
|
||||||
|
const KG: f32 = 1.0 - KR - KB;
|
||||||
|
|
||||||
|
/// One full-range RGB pixel (0..=255 channels) → the BT.709 limited-range 8-bit luma code
|
||||||
|
/// (16..=235). Kept in lockstep with the client-side inverse (`pf-client-core::video::csc_rows`).
|
||||||
|
fn luma709(r: f32, g: f32, b: f32) -> u8 {
|
||||||
|
let y = KR * r + KG * g + KB * b; // full-scale luma, 0..=255
|
||||||
|
(16.0 + y * (219.0 / 255.0) + 0.5) as u8 // `as` saturates — no manual clamp needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Averaged) full-range RGB → the BT.709 limited-range Cb/Cr codes (16..=240, neutral 128).
|
||||||
|
fn chroma709(r: f32, g: f32, b: f32) -> (u8, u8) {
|
||||||
|
let y = KR * r + KG * g + KB * b;
|
||||||
|
let cb = 128.0 + (b - y) * (224.0 / 255.0) / (2.0 * (1.0 - KB));
|
||||||
|
let cr = 128.0 + (r - y) * (224.0 / 255.0) / (2.0 * (1.0 - KR));
|
||||||
|
((cb + 0.5) as u8, (cr + 0.5) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
impl Encoder for OpenH264Encoder {
|
impl Encoder for OpenH264Encoder {
|
||||||
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
|
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
|
||||||
@@ -139,21 +170,13 @@ impl Encoder for OpenH264Encoder {
|
|||||||
self.src_format
|
self.src_format
|
||||||
);
|
);
|
||||||
|
|
||||||
match self.src_format {
|
// Source pixel stride + R/G/B byte offsets within a pixel — one converter for every
|
||||||
PixelFormat::Rgb => self
|
// packed-RGB layout the capturers emit (no BGRA normalization pass needed).
|
||||||
.yuv
|
let (bpp, ri, gi, bi) = match self.src_format {
|
||||||
.read_rgb(RgbSliceU8::new(&bytes[..w * h * 3], (w, h))),
|
PixelFormat::Rgb => (3, 0, 1, 2),
|
||||||
PixelFormat::Bgra | PixelFormat::Bgrx => self
|
PixelFormat::Bgr => (3, 2, 1, 0),
|
||||||
.yuv
|
PixelFormat::Rgba | PixelFormat::Rgbx => (4, 0, 1, 2),
|
||||||
.read_rgb(BgraSliceU8::new(&bytes[..w * h * 4], (w, h))),
|
PixelFormat::Bgra | PixelFormat::Bgrx => (4, 2, 1, 0),
|
||||||
PixelFormat::Rgba | PixelFormat::Rgbx => {
|
|
||||||
self.normalize_to_bgra(bytes, 4, true);
|
|
||||||
self.yuv.read_rgb(BgraSliceU8::new(&self.scratch, (w, h)));
|
|
||||||
}
|
|
||||||
PixelFormat::Bgr => {
|
|
||||||
self.normalize_to_bgra(bytes, 3, false);
|
|
||||||
self.yuv.read_rgb(BgraSliceU8::new(&self.scratch, (w, h)));
|
|
||||||
}
|
|
||||||
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
|
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
|
||||||
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
|
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
|
||||||
PixelFormat::Rgb10a2 => {
|
PixelFormat::Rgb10a2 => {
|
||||||
@@ -166,13 +189,19 @@ impl Encoder for OpenH264Encoder {
|
|||||||
"software encoder cannot encode YUV GPU textures (NV12/P010 → NVENC only)"
|
"software encoder cannot encode YUV GPU textures (NV12/P010 → NVENC only)"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
self.convert_bt709(bytes, bpp, ri, gi, bi);
|
||||||
|
|
||||||
if self.force_kf {
|
if self.force_kf {
|
||||||
self.enc.force_intra_frame();
|
self.enc.force_intra_frame();
|
||||||
self.force_kf = false;
|
self.force_kf = false;
|
||||||
}
|
}
|
||||||
let bs = self.enc.encode(&self.yuv).context("openh264 encode")?;
|
let slices = YUVSlices::new(
|
||||||
|
(&self.y_plane, &self.u_plane, &self.v_plane),
|
||||||
|
(w, h),
|
||||||
|
(w, w / 2, w / 2),
|
||||||
|
);
|
||||||
|
let bs = self.enc.encode(&slices).context("openh264 encode")?;
|
||||||
let mut data = Vec::new();
|
let mut data = Vec::new();
|
||||||
bs.write_vec(&mut data); // AnnexB start codes; SPS/PPS prepended on IDR
|
bs.write_vec(&mut data); // AnnexB start codes; SPS/PPS prepended on IDR
|
||||||
if !data.is_empty() {
|
if !data.is_empty() {
|
||||||
@@ -225,6 +254,51 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
use crate::capture::{CapturedFrame, FramePayload, PixelFormat};
|
||||||
|
|
||||||
|
/// The BT.709 limited-range anchor points: reference white → (235,128,128), black →
|
||||||
|
/// (16,128,128), pure red's Cr must hit the positive extreme 240 (it does exactly:
|
||||||
|
/// 255(1−Kr)·(224/255)/(2(1−Kr)) = 112). ±1 code for float rounding.
|
||||||
|
#[test]
|
||||||
|
fn bt709_conversion_anchor_points() {
|
||||||
|
assert_eq!(luma709(255.0, 255.0, 255.0), 235);
|
||||||
|
assert_eq!(luma709(0.0, 0.0, 0.0), 16);
|
||||||
|
assert_eq!(chroma709(255.0, 255.0, 255.0), (128, 128));
|
||||||
|
assert_eq!(chroma709(0.0, 0.0, 0.0), (128, 128));
|
||||||
|
let (cb, cr) = chroma709(255.0, 0.0, 0.0);
|
||||||
|
assert_eq!(cr, 240, "pure red must reach the Cr extreme");
|
||||||
|
assert!((101..=103).contains(&cb), "red Cb ~102, got {cb}");
|
||||||
|
let (cb, _) = chroma709(0.0, 0.0, 255.0);
|
||||||
|
assert_eq!(cb, 240, "pure blue must reach the Cb extreme");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 601-vs-709 luma split on pure green (Kg 0.587 vs 0.7152) — guards against anyone
|
||||||
|
/// "simplifying" the coefficients back to the crate's BT.601 converter (the hue-shift bug
|
||||||
|
/// this module's own conversion exists to prevent).
|
||||||
|
#[test]
|
||||||
|
fn bt709_is_not_bt601() {
|
||||||
|
// BT.601 green luma: 16 + 219·0.587 = 144.5; BT.709: 16 + 219·0.7152 = 172.6.
|
||||||
|
let y = luma709(0.0, 255.0, 0.0);
|
||||||
|
assert!((172..=174).contains(&y), "709 green luma ~173, got {y}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat gray frame converts to neutral chroma and mid luma across every plane byte
|
||||||
|
/// (exercises the block loop + plane sizing, not just the per-pixel math).
|
||||||
|
#[test]
|
||||||
|
fn converts_flat_gray_to_neutral_planes() {
|
||||||
|
let (w, h) = (16u32, 8u32);
|
||||||
|
let mut enc =
|
||||||
|
OpenH264Encoder::open(PixelFormat::Bgrx, w, h, 60, 1_000_000).expect("open openh264");
|
||||||
|
let bytes = vec![0x80u8; (w * h * 4) as usize];
|
||||||
|
enc.convert_bt709(&bytes, 4, 2, 1, 0);
|
||||||
|
// 16 + 128·(219/255) = 125.9 → 126.
|
||||||
|
assert!(
|
||||||
|
enc.y_plane.iter().all(|&y| y == 126),
|
||||||
|
"{:?}",
|
||||||
|
&enc.y_plane[..4]
|
||||||
|
);
|
||||||
|
assert!(enc.u_plane.iter().all(|&u| u == 128));
|
||||||
|
assert!(enc.v_plane.iter().all(|&v| v == 128));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn encodes_synthetic_frame_to_annexb_idr() {
|
fn encodes_synthetic_frame_to_annexb_idr() {
|
||||||
let (w, h, fps) = (1280u32, 720u32, 60u32);
|
let (w, h, fps) = (1280u32, 720u32, 60u32);
|
||||||
|
|||||||
@@ -708,11 +708,13 @@ impl NvencD3d11Encoder {
|
|||||||
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
|
// input — a subsampled NV12/P010 source can't reconstruct full chroma (so the capturer is
|
||||||
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
|
// forced to RGB for a 4:4:4 session, and we guard on the input format here too).
|
||||||
//
|
//
|
||||||
// ON-GLASS TODO (RTX box): confirm ARGB + chromaFormatIDC=3 + FREXT yields a *true* 4:4:4
|
// ON-GLASS MEASURED (RTX 5070 Ti, driver 610.43, 2026-07-10 — `nvenc_444_on_glass_probe`
|
||||||
// stream. NVENC's RGB→YUV CSC is documented to honor chromaFormatIDC (unlike libavcodec's
|
// below + colour-bar analysis): ARGB + chromaFormatIDC=3 + FREXT yields a TRUE 4:4:4
|
||||||
// wrapper, which always subsamples RGB to 4:2:0 — hence the Linux path feeds planar YUV444
|
// stream (1-px chroma stripes survive, adjacent-column |dU| ≈ 138), and NVENC's internal
|
||||||
// instead). If on-glass shows 4:2:0, the follow-up is a BGRA→AYUV shader feeding the native
|
// RGB→YUV conversion FOLLOWS THE CONFIGURED VUI MATRIX (bars match BT.709 within ±1 code
|
||||||
// `NV_ENC_BUFFER_FORMAT_AYUV` 4:4:4 input format.
|
// with our 709 VUI; the same driver produces exact BT.601 when libavcodec's nvenc wrapper
|
||||||
|
// sets its BT470BG VUI on Linux). The always-written SDR VUI above therefore makes the
|
||||||
|
// pixels and the signaling agree by construction — no AYUV shader needed.
|
||||||
let rgb_input = matches!(
|
let rgb_input = matches!(
|
||||||
self.buffer_fmt,
|
self.buffer_fmt,
|
||||||
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||||
@@ -752,21 +754,33 @@ impl NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// HDR colour signaling: BT.2020 primaries + SMPTE ST.2084 (PQ) transfer + BT.2020-NCL
|
// Colour signaling, written UNCONDITIONALLY (was HDR-only): the capturer hands NVENC
|
||||||
// matrix, limited (studio) range — NVENC's RGB→YUV default. HEVC/H.264 carry it in the VUI;
|
// pre-converted NV12 (BT.709 limited, the IDD VideoConverter) or P010 (BT.2020 PQ
|
||||||
// AV1 has NO VUI, so the SAME CICP code points go in the sequence-header colour config
|
// limited, the FP16→P010 shader), so the stream must SAY so — an SDR stream with no
|
||||||
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`). Without
|
// colour description decodes correctly only on clients whose "unspecified" default
|
||||||
// this a non-HEVC decoder assumes BT.709 SDR → washed-out / colour-shifted HDR.
|
// happens to be BT.709 limited (ours are, but Moonlight/third-party/Android-vendor
|
||||||
|
// decoders default 601 at sub-HD resolutions). HEVC/H.264 carry it in the VUI; AV1 has
|
||||||
|
// NO VUI, so the SAME CICP code points go in the sequence-header colour config
|
||||||
|
// (`colorPrimaries`/`transferCharacteristics`/`matrixCoefficients`/`colorRange`).
|
||||||
//
|
//
|
||||||
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
|
// This is the per-stream colour *description* only. The static mastering-display (ST.2086)
|
||||||
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
|
// and content-light (MaxCLL/MaxFALL) metadata — HEVC SEI / AV1 METADATA OBUs — is a
|
||||||
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
|
// separate follow-up, as is wiring AV1/H.264 to a true 10-bit (Main10) encode (only HEVC
|
||||||
// sets Main10 above today).
|
// sets Main10 above today).
|
||||||
if self.hdr {
|
{
|
||||||
let prim = nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
|
let (prim, trc, mat) = if self.hdr {
|
||||||
let trc =
|
(
|
||||||
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084;
|
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020,
|
||||||
let mat = nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
|
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084,
|
||||||
|
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT709,
|
||||||
|
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_BT709,
|
||||||
|
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT709,
|
||||||
|
)
|
||||||
|
};
|
||||||
match self.codec {
|
match self.codec {
|
||||||
Codec::H265 => {
|
Codec::H265 => {
|
||||||
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
|
||||||
@@ -1160,6 +1174,24 @@ impl Encoder for NvencD3d11Encoder {
|
|||||||
}
|
}
|
||||||
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
_ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
|
||||||
};
|
};
|
||||||
|
// 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
|
||||||
|
// subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
|
||||||
|
// native YUV despite a 4:4:4 negotiation, this session encodes 4:2:0 — clear the flag
|
||||||
|
// NOW so `caps().chroma_444` (and punktfunk1's post-open cross-check) reports what
|
||||||
|
// the stream really carries instead of silently claiming full chroma.
|
||||||
|
if self.chroma_444
|
||||||
|
&& !matches!(
|
||||||
|
self.buffer_fmt,
|
||||||
|
nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
|
||||||
|
| nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
|
||||||
|
)
|
||||||
|
{
|
||||||
|
tracing::warn!(
|
||||||
|
format = ?captured.format,
|
||||||
|
"4:4:4 negotiated but the capturer delivered subsampled YUV — encoding 4:2:0"
|
||||||
|
);
|
||||||
|
self.chroma_444 = false;
|
||||||
|
}
|
||||||
let device = frame.device.clone();
|
let device = frame.device.clone();
|
||||||
self.init_session(&device)?;
|
self.init_session(&device)?;
|
||||||
self.init_device = dev_raw;
|
self.init_device = dev_raw;
|
||||||
@@ -1573,3 +1605,166 @@ pub fn probe_can_encode_444(codec: Codec) -> bool {
|
|||||||
ok
|
ok
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::capture::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
|
||||||
|
use windows::Win32::Graphics::Direct3D11::{
|
||||||
|
D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
|
||||||
|
};
|
||||||
|
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
|
||||||
|
use windows::Win32::Graphics::Dxgi::{
|
||||||
|
CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The 8 fully-saturated colour bars the matrix analysis samples (RGB). Saturated primaries
|
||||||
|
/// separate BT.601 from BT.709 by tens of code points (e.g. pure-green luma 145 vs 173).
|
||||||
|
const BARS: [(u8, u8, u8); 8] = [
|
||||||
|
(255, 255, 255), // white
|
||||||
|
(255, 255, 0), // yellow
|
||||||
|
(0, 255, 255), // cyan
|
||||||
|
(0, 255, 0), // green
|
||||||
|
(255, 0, 255), // magenta
|
||||||
|
(255, 0, 0), // red
|
||||||
|
(0, 0, 255), // blue
|
||||||
|
(0, 0, 0), // black
|
||||||
|
];
|
||||||
|
|
||||||
|
/// BGRA probe pattern: left half = the 8 colour bars (flat patches → matrix measurement),
|
||||||
|
/// right half = alternating 1-px red/blue columns (the chroma-resolution litmus: true 4:4:4
|
||||||
|
/// keeps adjacent columns' chroma distinct; an internally-subsampled encode blends them).
|
||||||
|
fn probe_pattern(w: usize, h: usize) -> Vec<u8> {
|
||||||
|
let mut px = vec![0u8; w * h * 4];
|
||||||
|
let bar_w = (w / 2) / BARS.len();
|
||||||
|
for y in 0..h {
|
||||||
|
for x in 0..w {
|
||||||
|
let (r, g, b) = if x < w / 2 {
|
||||||
|
BARS[(x / bar_w).min(BARS.len() - 1)]
|
||||||
|
} else if x % 2 == 0 {
|
||||||
|
(255, 0, 0) // red column
|
||||||
|
} else {
|
||||||
|
(0, 0, 255) // blue column
|
||||||
|
};
|
||||||
|
let o = (y * w + x) * 4;
|
||||||
|
px[o] = b;
|
||||||
|
px[o + 1] = g;
|
||||||
|
px[o + 2] = r;
|
||||||
|
px[o + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
px
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode 30 static pattern frames through the real NVENC session (ARGB input, the exact
|
||||||
|
/// production configuration) at the given chroma and write the Annex-B stream to `path`.
|
||||||
|
fn encode_pattern(chroma: ChromaFormat, path: &str) {
|
||||||
|
const W: u32 = 1280;
|
||||||
|
const H: u32 = 720;
|
||||||
|
// SAFETY (test-only): straight-line D3D11/DXGI COM calls on one thread; every out-pointer
|
||||||
|
// is checked before use; the texture/device outlive the encoder (dropped at scope end).
|
||||||
|
unsafe {
|
||||||
|
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
|
||||||
|
let mut adapter = None;
|
||||||
|
for i in 0.. {
|
||||||
|
let Ok(a) = factory.EnumAdapters1(i) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let desc = a.GetDesc1().expect("adapter desc");
|
||||||
|
if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
|
||||||
|
adapter = Some(a);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let adapter = adapter.expect("no hardware DXGI adapter");
|
||||||
|
let (device, _ctx) = crate::capture::dxgi::make_device(&adapter).expect("make_device");
|
||||||
|
|
||||||
|
let bytes = probe_pattern(W as usize, H as usize);
|
||||||
|
let init = D3D11_SUBRESOURCE_DATA {
|
||||||
|
pSysMem: bytes.as_ptr() as *const _,
|
||||||
|
SysMemPitch: W * 4,
|
||||||
|
SysMemSlicePitch: 0,
|
||||||
|
};
|
||||||
|
let desc = D3D11_TEXTURE2D_DESC {
|
||||||
|
Width: W,
|
||||||
|
Height: H,
|
||||||
|
MipLevels: 1,
|
||||||
|
ArraySize: 1,
|
||||||
|
Format: DXGI_FORMAT_B8G8R8A8_UNORM,
|
||||||
|
SampleDesc: DXGI_SAMPLE_DESC {
|
||||||
|
Count: 1,
|
||||||
|
Quality: 0,
|
||||||
|
},
|
||||||
|
Usage: D3D11_USAGE_DEFAULT,
|
||||||
|
// NVENC registration requires RENDER_TARGET on D3D11 input textures.
|
||||||
|
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||||
|
CPUAccessFlags: 0,
|
||||||
|
MiscFlags: 0,
|
||||||
|
};
|
||||||
|
let mut tex = None;
|
||||||
|
device
|
||||||
|
.CreateTexture2D(&desc, Some(&init), Some(&mut tex))
|
||||||
|
.expect("pattern texture");
|
||||||
|
let tex = tex.expect("null pattern texture");
|
||||||
|
|
||||||
|
let mut enc = NvencD3d11Encoder::open(
|
||||||
|
Codec::H265,
|
||||||
|
PixelFormat::Bgra,
|
||||||
|
W,
|
||||||
|
H,
|
||||||
|
60,
|
||||||
|
100_000_000, // high rate: the 1-px stripes must survive quantization
|
||||||
|
8,
|
||||||
|
chroma,
|
||||||
|
)
|
||||||
|
.expect("NVENC open");
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for i in 0..30u64 {
|
||||||
|
let frame = CapturedFrame {
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
pts_ns: i * 16_666_667,
|
||||||
|
format: PixelFormat::Bgra,
|
||||||
|
payload: FramePayload::D3d11(D3d11Frame {
|
||||||
|
texture: tex.clone(),
|
||||||
|
device: device.clone(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
enc.submit(&frame).expect("submit");
|
||||||
|
while let Some(au) = enc.poll().expect("poll") {
|
||||||
|
out.extend_from_slice(&au.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enc.flush().ok();
|
||||||
|
while let Ok(Some(au)) = enc.poll() {
|
||||||
|
out.extend_from_slice(&au.data);
|
||||||
|
}
|
||||||
|
assert!(!out.is_empty(), "no AUs produced");
|
||||||
|
let caps444 = enc.caps().chroma_444;
|
||||||
|
std::fs::write(path, &out).expect("write bitstream");
|
||||||
|
println!(
|
||||||
|
"wrote {path}: {} bytes, requested {chroma:?}, caps.chroma_444={caps444}",
|
||||||
|
out.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
|
||||||
|
/// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT
|
||||||
|
/// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
|
||||||
|
/// the FREXT stream is truly full-chroma and (2) which matrix NVENC's internal RGB→YUV CSC
|
||||||
|
/// used (BT.601 vs BT.709 — saturated bars differ by tens of code points). Run with:
|
||||||
|
/// cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_444_on_glass --nocapture
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box"]
|
||||||
|
fn nvenc_444_on_glass_probe() {
|
||||||
|
encode_pattern(
|
||||||
|
ChromaFormat::Yuv444,
|
||||||
|
"C:\\Users\\Public\\nvenc444_probe.h265",
|
||||||
|
);
|
||||||
|
encode_pattern(
|
||||||
|
ChromaFormat::Yuv420,
|
||||||
|
"C:\\Users\\Public\\nvenc420_probe.h265",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,9 +257,9 @@ fn run(
|
|||||||
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
audio_cap: &std::sync::Mutex<Option<Box<dyn AudioCapturer>>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
let sock = UdpSocket::bind(("0.0.0.0", AUDIO_PORT)).context("bind audio UDP")?;
|
||||||
// Grow SO_SNDBUF/RCVBUF + opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1).
|
// Grow SO_SNDBUF/RCVBUF; the opt-in DSCP/QoS tag happens after connect below (Windows
|
||||||
|
// qWAVE derives the flow from the connected 5-tuple).
|
||||||
punktfunk_core::transport::grow_socket_buffers(&sock);
|
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||||
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Audio);
|
|
||||||
// The client pings the audio port (~every 500ms) so we learn where to send.
|
// The client pings the audio port (~every 500ms) so we learn where to send.
|
||||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||||
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
|
tracing::info!(port = AUDIO_PORT, "audio: awaiting client ping");
|
||||||
@@ -269,6 +269,12 @@ fn run(
|
|||||||
.context("audio: no client ping within 10s")?;
|
.context("audio: no client ping within 10s")?;
|
||||||
sock.connect(client)
|
sock.connect(client)
|
||||||
.context("connect client audio endpoint")?;
|
.context("connect client audio endpoint")?;
|
||||||
|
// Opt-in DSCP/QoS-tag this as the audio class (PUNKTFUNK_DSCP=1); the guard keeps the
|
||||||
|
// Windows qWAVE flow alive for the whole stream (this function's scope IS the stream).
|
||||||
|
let _qos_flow = punktfunk_core::transport::set_media_qos(
|
||||||
|
&sock,
|
||||||
|
punktfunk_core::transport::MediaClass::Audio,
|
||||||
|
);
|
||||||
tracing::info!(%client, "audio: client endpoint learned");
|
tracing::info!(%client, "audio: client endpoint learned");
|
||||||
|
|
||||||
// Reuse the persistent capturer when its channel count still matches (drain stale
|
// Reuse the persistent capturer when its channel count still matches (drain stale
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
|||||||
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||||
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||||
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||||
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push
|
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely on GameStream: stock Moonlight is 4:2:0 —
|
||||||
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`).
|
/// full-chroma is a punktfunk/1-native negotiation only (`crate::capture::capturer_supports_444`).
|
||||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
||||||
|
|
||||||
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
||||||
|
|||||||
@@ -380,6 +380,47 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
|||||||
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// The client's requested CSC (moonlight-common-c SdpGenerator.c: `encoderCscMode =
|
||||||
|
// (colorspace << 1) | fullRange` — colorspace 0=Rec601, 1=Rec709, 2=Rec2020). Moonlight
|
||||||
|
// renderers configure their YUV→RGB from this REQUESTED value (not the bitstream VUI), so a
|
||||||
|
// host that encodes something else shifts the client's colours. INSTRUMENTATION ONLY for
|
||||||
|
// now: we always encode BT.709 limited for SDR (the IDD VideoConverter / VUI-driven NVENC)
|
||||||
|
// and BT.2020 PQ for HDR — log what clients actually ask for so honoring `encoderCscMode`
|
||||||
|
// can be scoped from field data rather than guessed. (Absent on very old clients.)
|
||||||
|
if let Some(csc) = parse_u("x-nv-video[0].encoderCscMode") {
|
||||||
|
let (space, range) = (
|
||||||
|
match csc >> 1 {
|
||||||
|
0 => "Rec601",
|
||||||
|
1 => "Rec709",
|
||||||
|
2 => "Rec2020",
|
||||||
|
_ => "unknown",
|
||||||
|
},
|
||||||
|
if csc & 1 != 0 { "full" } else { "limited" },
|
||||||
|
);
|
||||||
|
let ours = if hdr {
|
||||||
|
"Rec2020 limited (PQ)"
|
||||||
|
} else {
|
||||||
|
"Rec709 limited"
|
||||||
|
};
|
||||||
|
let matches_ours = (hdr && csc >> 1 == 2 || !hdr && csc >> 1 == 1) && csc & 1 == 0;
|
||||||
|
if matches_ours {
|
||||||
|
tracing::info!(
|
||||||
|
csc,
|
||||||
|
space,
|
||||||
|
range,
|
||||||
|
"GameStream client requested CSC — matches ours"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(
|
||||||
|
csc,
|
||||||
|
requested = format!("{space} {range}"),
|
||||||
|
encoding = ours,
|
||||||
|
"GameStream client requested a CSC we don't encode — Moonlight renders by its \
|
||||||
|
REQUEST, so its colours will be shifted (honoring encoderCscMode is a known \
|
||||||
|
follow-up; report this log line)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
||||||
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
|
let min_fec = parse_u("x-nv-vqos[0].fec.minRequiredFecPackets")
|
||||||
.unwrap_or(2)
|
.unwrap_or(2)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ use super::VIDEO_PORT;
|
|||||||
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
use crate::capture::{self, Capturer, FastSyntheticCapturer};
|
||||||
use crate::encode::{self, Codec};
|
use crate::encode::{self, Codec};
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use rand::Rng;
|
|
||||||
use std::net::UdpSocket;
|
use std::net::UdpSocket;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -95,10 +94,10 @@ fn run(
|
|||||||
encode::validate_dimensions(cfg.codec, cfg.width, cfg.height)
|
encode::validate_dimensions(cfg.codec, cfg.width, cfg.height)
|
||||||
.context("client-requested video mode")?;
|
.context("client-requested video mode")?;
|
||||||
let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?;
|
let sock = UdpSocket::bind(("0.0.0.0", VIDEO_PORT)).context("bind video UDP")?;
|
||||||
// Grow SO_SNDBUF/RCVBUF (avoid host-side ENOBUFS at high bitrate) like the native plane, and
|
// Grow SO_SNDBUF/RCVBUF (avoid host-side ENOBUFS at high bitrate) like the native plane.
|
||||||
// opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1).
|
// The opt-in DSCP/QoS tag happens after connect below (Windows qWAVE derives the flow from
|
||||||
|
// the connected 5-tuple).
|
||||||
punktfunk_core::transport::grow_socket_buffers(&sock);
|
punktfunk_core::transport::grow_socket_buffers(&sock);
|
||||||
punktfunk_core::transport::set_media_qos(&sock, punktfunk_core::transport::MediaClass::Video);
|
|
||||||
// The client pings the video port so we learn where to send; it re-pings until video
|
// The client pings the video port so we learn where to send; it re-pings until video
|
||||||
// flows, so a missed early ping is fine.
|
// flows, so a missed early ping is fine.
|
||||||
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
sock.set_read_timeout(Some(Duration::from_secs(10)))?;
|
||||||
@@ -112,6 +111,12 @@ fn run(
|
|||||||
.context("video: no client ping within 10s")?;
|
.context("video: no client ping within 10s")?;
|
||||||
sock.connect(client)
|
sock.connect(client)
|
||||||
.context("connect client video endpoint")?;
|
.context("connect client video endpoint")?;
|
||||||
|
// Opt-in DSCP/QoS-tag this as the video class (PUNKTFUNK_DSCP=1); the guard keeps the
|
||||||
|
// Windows qWAVE flow alive for the whole stream (this function's scope IS the stream).
|
||||||
|
let _qos_flow = punktfunk_core::transport::set_media_qos(
|
||||||
|
&sock,
|
||||||
|
punktfunk_core::transport::MediaClass::Video,
|
||||||
|
);
|
||||||
tracing::info!(%client, "video: client endpoint learned");
|
tracing::info!(%client, "video: client endpoint learned");
|
||||||
// Short label for web-console stats captures: the client's peer IP.
|
// Short label for web-console stats captures: the client's peer IP.
|
||||||
let client_label = client.ip().to_string();
|
let client_label = client.ip().to_string();
|
||||||
@@ -422,20 +427,6 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows
|
|
||||||
/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds
|
|
||||||
/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a
|
|
||||||
/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send
|
|
||||||
/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps
|
|
||||||
/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent.
|
|
||||||
fn pace_layout(n: usize) -> (usize, usize) {
|
|
||||||
const MIN_PACE_CHUNK: usize = 16;
|
|
||||||
const MAX_PACE_STEPS: usize = 12;
|
|
||||||
let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS));
|
|
||||||
let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS
|
|
||||||
(chunk_sz, steps)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access
|
/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access
|
||||||
/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC
|
/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC
|
||||||
/// packetization runs on the packetizer thread — off the encode loop — so it never serializes
|
/// packetization runs on the packetizer thread — off the encode loop — so it never serializes
|
||||||
@@ -485,15 +476,16 @@ fn spawn_packetizer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
|
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
|
||||||
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
|
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval — the
|
||||||
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
|
/// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
|
||||||
/// thread is never blocked by this). On send failure (client gone) it clears `running`.
|
/// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
|
||||||
|
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
|
||||||
|
/// of the fixed budget. On send failure (client gone) it clears `running`.
|
||||||
fn spawn_sender(
|
fn spawn_sender(
|
||||||
sock: UdpSocket,
|
sock: UdpSocket,
|
||||||
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
||||||
frame_interval: Duration,
|
frame_interval: Duration,
|
||||||
running: Arc<AtomicBool>,
|
running: Arc<AtomicBool>,
|
||||||
drop_pct: u32,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
std::thread::Builder::new()
|
std::thread::Builder::new()
|
||||||
.name("punktfunk-send".into())
|
.name("punktfunk-send".into())
|
||||||
@@ -501,53 +493,38 @@ fn spawn_sender(
|
|||||||
// Transmit thread: above-normal, matching the native path's send thread (includes the
|
// Transmit thread: above-normal, matching the native path's send thread (includes the
|
||||||
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
|
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
|
||||||
crate::punktfunk1::boost_thread_priority(false);
|
crate::punktfunk1::boost_thread_priority(false);
|
||||||
// Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
|
|
||||||
// of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a
|
|
||||||
// 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this
|
|
||||||
// non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms
|
|
||||||
// budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and
|
|
||||||
// dropping ~half the frames ("send queue full"). Capping the step count keeps the
|
|
||||||
// microburst shaping (a real link drops line-rate bursts) while making per-frame sleep
|
|
||||||
// overshoot negligible and independent of bitrate.
|
|
||||||
let budget = frame_interval.mul_f32(0.75);
|
let budget = frame_interval.mul_f32(0.75);
|
||||||
let mut rng = rand::thread_rng();
|
let cfg = crate::send_pacing::PaceCfg {
|
||||||
|
burst_bytes: None, // no microburst stage — the whole frame spreads
|
||||||
|
chunk: crate::send_pacing::ChunkPolicy::Bounded {
|
||||||
|
min_chunk: 16,
|
||||||
|
max_steps: 12,
|
||||||
|
},
|
||||||
|
sleep_floor: Duration::from_micros(500),
|
||||||
|
};
|
||||||
let mut sent: u64 = 0;
|
let mut sent: u64 = 0;
|
||||||
let mut dropped: u64 = 0;
|
let mut dropped: u64 = 0;
|
||||||
while let Ok(mut batch) = rx.recv() {
|
while let Ok(mut batch) = rx.recv() {
|
||||||
if drop_pct > 0 {
|
// FEC test knob (PUNKTFUNK_VIDEO_DROP) — same knob the native plane honors.
|
||||||
batch.retain(|_| {
|
dropped += crate::send_pacing::inject_video_drop(&mut batch);
|
||||||
let keep = rng.gen_range(0..100) >= drop_pct;
|
if batch.is_empty() {
|
||||||
if !keep {
|
|
||||||
dropped += 1;
|
|
||||||
}
|
|
||||||
keep
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let n = batch.len();
|
|
||||||
if n == 0 {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into
|
let r = crate::send_pacing::pace_frame(
|
||||||
// dozens of sleeps. Each step gets an equal slice of the budget (total pacing time
|
&batch,
|
||||||
// == budget regardless of n).
|
crate::send_pacing::PaceBudget::Fixed(budget),
|
||||||
let (chunk_sz, steps) = pace_layout(n);
|
&cfg,
|
||||||
let per_step = budget.mul_f64(1.0 / steps as f64);
|
|chunk| {
|
||||||
let start = Instant::now();
|
sendmmsg_all(&sock, chunk)?;
|
||||||
for (i, chunk) in batch.chunks(chunk_sz).enumerate() {
|
sent += chunk.len() as u64;
|
||||||
if let Err(e) = sendmmsg_all(&sock, chunk) {
|
Ok::<(), std::io::Error>(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if let Err(e) = r {
|
||||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
||||||
running.store(false, Ordering::SeqCst);
|
running.store(false, Ordering::SeqCst);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sent += chunk.len() as u64;
|
|
||||||
// Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
|
|
||||||
let target = start + per_step.mul_f64((i + 1) as f64);
|
|
||||||
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
|
|
||||||
if ahead >= Duration::from_micros(500) {
|
|
||||||
std::thread::sleep(ahead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tracing::debug!(sent, dropped, "video sender exiting");
|
tracing::debug!(sent, dropped, "video sender exiting");
|
||||||
})
|
})
|
||||||
@@ -555,16 +532,7 @@ fn spawn_sender(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the web-console
|
use crate::send_pacing::percentile;
|
||||||
/// stats sample's per-stage p50/p99.
|
|
||||||
fn percentile(v: &mut [u32], q: f64) -> u32 {
|
|
||||||
if v.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
v.sort_unstable();
|
|
||||||
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
|
|
||||||
v[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||||
@@ -627,11 +595,6 @@ fn stream_body(
|
|||||||
let mut fps_count: u32 = 0;
|
let mut fps_count: u32 = 0;
|
||||||
let mut fps_t = Instant::now();
|
let mut fps_t = Instant::now();
|
||||||
let stream_start = Instant::now();
|
let stream_start = Instant::now();
|
||||||
// Test knob: drop this % of outbound packets to exercise FEC recovery (0 = off).
|
|
||||||
let drop_pct: u32 = std::env::var("PUNKTFUNK_VIDEO_DROP")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let mut sent_batches: u64 = 0;
|
let mut sent_batches: u64 = 0;
|
||||||
let mut dropped_batches: u64 = 0;
|
let mut dropped_batches: u64 = 0;
|
||||||
|
|
||||||
@@ -650,7 +613,6 @@ fn stream_body(
|
|||||||
batch_rx,
|
batch_rx,
|
||||||
Duration::from_secs_f64(1.0 / target_fps as f64),
|
Duration::from_secs_f64(1.0 / target_fps as f64),
|
||||||
running.clone(),
|
running.clone(),
|
||||||
drop_pct,
|
|
||||||
)?;
|
)?;
|
||||||
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
||||||
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
||||||
@@ -989,7 +951,6 @@ mod tests {
|
|||||||
rx,
|
rx,
|
||||||
Duration::from_millis(8), // ~120fps frame interval
|
Duration::from_millis(8), // ~120fps frame interval
|
||||||
running.clone(),
|
running.clone(),
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -1026,30 +987,4 @@ mod tests {
|
|||||||
assert_eq!(got, 3 * PER_FRAME);
|
assert_eq!(got, 3 * PER_FRAME);
|
||||||
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size,
|
|
||||||
/// while always covering every packet and keeping small frames on the 16-packet floor. Guards
|
|
||||||
/// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600
|
|
||||||
/// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget).
|
|
||||||
#[test]
|
|
||||||
fn pace_layout_bounds_step_count() {
|
|
||||||
for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] {
|
|
||||||
let (chunk, steps) = pace_layout(n);
|
|
||||||
assert!(steps >= 1, "n={n}: at least one step");
|
|
||||||
assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap");
|
|
||||||
assert!(
|
|
||||||
chunk >= 16,
|
|
||||||
"n={n}: chunk {chunk} below the 16-packet floor"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
chunk * steps >= n,
|
|
||||||
"n={n}: {chunk}×{steps} must cover all packets"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Small frames stay on the floor: one 16-packet burst.
|
|
||||||
assert_eq!(pace_layout(1), (16, 1));
|
|
||||||
assert_eq!(pace_layout(16), (16, 1));
|
|
||||||
// A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded.
|
|
||||||
assert!(pace_layout(610).1 <= 12);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ impl VideoPacketizer {
|
|||||||
};
|
};
|
||||||
let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
|
let wire_pct = if m > 0 { (100 * m) / k } else { 0 };
|
||||||
let parity = if m > 0 {
|
let parity = if m > 0 {
|
||||||
Gf8Coder.encode(&shards, m).unwrap_or_default()
|
let refs: Vec<&[u8]> = shards.iter().map(|s| s.as_slice()).collect();
|
||||||
|
Gf8Coder.encode(&refs, m).unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ use super::egl::DmabufPlane;
|
|||||||
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
use super::proto::{self, BufferDesc, ImportKind, Reply, Request};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::fs::File;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
|
||||||
use std::path::Path;
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command};
|
use std::process::{Child, Command};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
/// Handshake budget: EGL + CUDA bring-up is ~200 ms; a cold driver load can take seconds.
|
||||||
@@ -69,6 +71,57 @@ fn sweep_reaper() {
|
|||||||
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
list.retain_mut(|c| !matches!(c.try_wait(), Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fd pinned to this process's own executable image, opened (once, lazily) via the
|
||||||
|
/// `/proc/self/exe` magic link. The link names the running image's *inode*, not its path, so it
|
||||||
|
/// resolves even after the installed binary was replaced or deleted — and exec'ing the fd (via
|
||||||
|
/// [`fd_exec_path`]) then still runs byte-for-byte the build this process is. `current_exe()`
|
||||||
|
/// instead readlinks to a path: after a package upgrade under a running host that path is
|
||||||
|
/// "<path> (deleted)" and spawning it fails ENOENT — every capture then silently fell back to
|
||||||
|
/// the CPU copy — and even while the path exists it may hold a newer build whose worker
|
||||||
|
/// protocol mismatches this process.
|
||||||
|
static SELF_EXE: OnceLock<Option<File>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn self_exe() -> Option<BorrowedFd<'static>> {
|
||||||
|
SELF_EXE
|
||||||
|
.get_or_init(|| {
|
||||||
|
let f = match File::open("/proc/self/exe") {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(
|
||||||
|
error = %e,
|
||||||
|
"cannot pin /proc/self/exe — worker spawns use the current_exe() path, \
|
||||||
|
which breaks if this binary is replaced on disk"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if f.as_raw_fd() != 3 {
|
||||||
|
return Some(f);
|
||||||
|
}
|
||||||
|
// Fd 3 is the slot the spawn hands the worker its socket on (the `dup2` in
|
||||||
|
// `spawn_exe`) — pinned there, the child would clobber it before exec resolves
|
||||||
|
// `/proc/self/fd/3`. Re-number: 3 stays occupied by `f` during the clone, so the
|
||||||
|
// duplicate cannot land on it.
|
||||||
|
match f.try_clone() {
|
||||||
|
Ok(clone) => Some(clone),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "re-numbering the pinned exe fd off fd 3 failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.as_fd())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/proc/self/fd/<n>` — an exec'able path to `fd`'s inode. The kernel resolves it at exec time
|
||||||
|
/// inside the forked child, whose fd table is a copy of ours (close-on-exec applies only once
|
||||||
|
/// the exec succeeds), so it names the pinned inode no matter what sits at the file's original
|
||||||
|
/// path by then.
|
||||||
|
fn fd_exec_path(fd: BorrowedFd<'_>) -> PathBuf {
|
||||||
|
PathBuf::from(format!("/proc/self/fd/{}", fd.as_raw_fd()))
|
||||||
|
}
|
||||||
|
|
||||||
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
/// The remote (isolated) importer — one per capture. Method-for-method mirror of the in-process
|
||||||
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
/// [`super::egl::EglImporter`] surface the capture thread uses.
|
||||||
pub struct RemoteImporter {
|
pub struct RemoteImporter {
|
||||||
@@ -81,12 +134,18 @@ pub struct RemoteImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RemoteImporter {
|
impl RemoteImporter {
|
||||||
/// Spawn the worker from this host binary and complete the readiness handshake. An `Err`
|
/// Spawn the worker from this host binary and complete the readiness handshake. The worker
|
||||||
/// here means "no isolated zero-copy available" — callers fall back to the CPU path, exactly
|
/// is exec'd through the pinned [`SELF_EXE`] fd, so it is always the exact image this
|
||||||
/// like an in-process `EglImporter::new()` failure.
|
/// process runs — even after the installed binary was replaced mid-flight. An `Err` here
|
||||||
|
/// means "no isolated zero-copy available" — callers fall back to the CPU path, exactly like
|
||||||
|
/// an in-process `EglImporter::new()` failure.
|
||||||
pub fn spawn() -> Result<RemoteImporter> {
|
pub fn spawn() -> Result<RemoteImporter> {
|
||||||
let exe = std::env::current_exe().context("resolve /proc/self/exe for the worker")?;
|
match self_exe() {
|
||||||
Self::spawn_exe(&exe)
|
Some(fd) => Self::spawn_exe(&fd_exec_path(fd)),
|
||||||
|
None => Self::spawn_exe(
|
||||||
|
&std::env::current_exe().context("resolve /proc/self/exe for the worker")?,
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
/// [`Self::spawn`] with an explicit executable (separated for tests).
|
||||||
@@ -94,6 +153,8 @@ impl RemoteImporter {
|
|||||||
sweep_reaper();
|
sweep_reaper();
|
||||||
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
let (host_end, worker_end) = proto::socketpair_seqpacket().context("worker socketpair")?;
|
||||||
let mut cmd = Command::new(exe);
|
let mut cmd = Command::new(exe);
|
||||||
|
// `exe` is normally an opaque `/proc/self/fd/<n>` — keep `ps` output meaningful.
|
||||||
|
cmd.arg0("punktfunk-host");
|
||||||
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
cmd.arg("zerocopy-worker").arg("--fd").arg("3");
|
||||||
let raw = worker_end.as_raw_fd();
|
let raw = worker_end.as_raw_fd();
|
||||||
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
// SAFETY: `pre_exec` runs between fork and exec, so only async-signal-safe calls are
|
||||||
@@ -102,7 +163,6 @@ impl RemoteImporter {
|
|||||||
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
|
// the subcommand expects and clears CLOEXEC on the copy; if the parent's fd already IS 3,
|
||||||
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
// `dup2(3,3)` would preserve CLOEXEC, so that case clears the flag explicitly instead.
|
||||||
unsafe {
|
unsafe {
|
||||||
use std::os::unix::process::CommandExt;
|
|
||||||
cmd.pre_exec(move || {
|
cmd.pre_exec(move || {
|
||||||
if raw == 3 {
|
if raw == 3 {
|
||||||
let flags = libc::fcntl(3, libc::F_GETFD);
|
let flags = libc::fcntl(3, libc::F_GETFD);
|
||||||
@@ -483,6 +543,48 @@ mod tests {
|
|||||||
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawn_execs_the_pinned_self_exe() {
|
||||||
|
// `spawn()` execs this very process's image via the pinned `/proc/self/fd/…` path. Here
|
||||||
|
// that image is the libtest harness, which rejects `--fd` and exits without a handshake
|
||||||
|
// — so a "handshake" error proves the exec itself succeeded (an exec failure would read
|
||||||
|
// "spawn zerocopy-worker" instead).
|
||||||
|
let Err(err) = RemoteImporter::spawn() else {
|
||||||
|
panic!("the test harness is not a worker; spawn must fail at the handshake")
|
||||||
|
};
|
||||||
|
assert!(format!("{err:#}").contains("handshake"), "{err:#}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pinned_fd_exec_survives_on_disk_replacement() {
|
||||||
|
// The 2026-07-10 canary regression: a package upgrade replaced the installed binary and
|
||||||
|
// every worker spawn ENOENT'd (`current_exe()` readlinked to "<path> (deleted)"). The
|
||||||
|
// pinned-fd mechanism must keep exec'ing the original image after the file is gone: pin
|
||||||
|
// a copy of /bin/sh, delete it, then run it through the fd path.
|
||||||
|
let copy = std::env::temp_dir().join(format!("pf-zerocopy-exe-pin-{}", std::process::id()));
|
||||||
|
std::fs::copy("/bin/sh", ©).unwrap();
|
||||||
|
let pinned = File::open(©).unwrap();
|
||||||
|
std::fs::remove_file(©).unwrap();
|
||||||
|
// Retry ETXTBSY: `fs::copy`'s write fd leaks into other tests' concurrently-forked
|
||||||
|
// children until their execs clear it (CLOEXEC applies only at exec), and exec'ing a
|
||||||
|
// file someone holds open for writing is refused. A harness artifact of copy-then-exec,
|
||||||
|
// not the mechanism under test — production pins a read-only fd on a binary nobody
|
||||||
|
// write-opens.
|
||||||
|
let status = loop {
|
||||||
|
match Command::new(fd_exec_path(pinned.as_fd()))
|
||||||
|
.arg("-c")
|
||||||
|
.arg("exit 42")
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) => {
|
||||||
|
std::thread::sleep(Duration::from_millis(10))
|
||||||
|
}
|
||||||
|
other => break other.expect("exec via /proc/self/fd of a deleted file"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert_eq!(status.code(), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
/// A scripted peer: answers the handshake, then serves canned replies per request.
|
||||||
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
fn scripted_server(replies: Vec<Reply>) -> (RemoteImporter, thread::JoinHandle<Vec<Request>>) {
|
||||||
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
let (host, worker) = proto::socketpair_seqpacket().unwrap();
|
||||||
|
|||||||
@@ -221,11 +221,20 @@ pub fn drm_fourcc(format: crate::capture::PixelFormat) -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
|
/// Standalone probe (the `zerocopy-probe` subcommand): initialize the EGL importer + CUDA
|
||||||
/// context and report. De-risks the FFI/linking/GPU-access without needing a capture session.
|
/// context and report, then exercise the production path — spawn the isolated worker (exec'd
|
||||||
|
/// from this binary's pinned exe fd), handshake, and query modifiers. De-risks the
|
||||||
|
/// FFI/linking/GPU-access AND the worker spawn (e.g. the installed binary replaced under a
|
||||||
|
/// running host) without needing a capture session.
|
||||||
pub fn probe() -> anyhow::Result<()> {
|
pub fn probe() -> anyhow::Result<()> {
|
||||||
let _importer = EglImporter::new()?;
|
let _importer = EglImporter::new()?;
|
||||||
let ctx = cuda::context()?;
|
let ctx = cuda::context()?;
|
||||||
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
|
tracing::info!(cuda_ctx = ?ctx, "zero-copy probe OK — EGL display + CUDA context initialized");
|
||||||
|
let mut worker = client::RemoteImporter::spawn()?;
|
||||||
|
let modifiers = worker.supported_modifiers(fourcc(b"XR24")).len();
|
||||||
|
tracing::info!(
|
||||||
|
modifiers,
|
||||||
|
"zero-copy probe OK — worker spawned, handshake + modifier query"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ const FD_CACHE_CAP: usize = 64;
|
|||||||
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
|
/// Entry point for the hidden `zerocopy-worker` subcommand. `args` are the subcommand's own
|
||||||
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
/// arguments (`--fd N`, default 3 — the socket end the spawning host `dup2`'d in).
|
||||||
pub fn run_from_args(args: &[String]) -> Result<()> {
|
pub fn run_from_args(args: &[String]) -> Result<()> {
|
||||||
|
// The host execs this worker through its pinned exe fd (`client::self_exe`), so the kernel
|
||||||
|
// derives our comm from the exec path's basename — a meaningless fd number. Rename so
|
||||||
|
// `top`/`pkill` see the worker.
|
||||||
|
// SAFETY: `PR_SET_NAME` copies at most 16 bytes from the given pointer; the C-string literal
|
||||||
|
// is valid, NUL-terminated, and short enough. No pointer is retained past the call.
|
||||||
|
unsafe {
|
||||||
|
libc::prctl(libc::PR_SET_NAME, c"pf-zerocopy".as_ptr());
|
||||||
|
}
|
||||||
let fd: i32 = args
|
let fd: i32 = args
|
||||||
.iter()
|
.iter()
|
||||||
.skip_while(|a| *a != "--fd")
|
.skip_while(|a| *a != "--fd")
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ mod wol;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/crash.rs"]
|
#[path = "windows/crash.rs"]
|
||||||
mod crash;
|
mod crash;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/ddc.rs"]
|
||||||
|
mod ddc;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
#[path = "linux/dmabuf_fence.rs"]
|
#[path = "linux/dmabuf_fence.rs"]
|
||||||
mod dmabuf_fence;
|
mod dmabuf_fence;
|
||||||
@@ -50,12 +53,17 @@ mod install;
|
|||||||
mod interactive;
|
mod interactive;
|
||||||
mod library;
|
mod library;
|
||||||
mod log_capture;
|
mod log_capture;
|
||||||
|
mod metronome;
|
||||||
mod mgmt;
|
mod mgmt;
|
||||||
mod mgmt_token;
|
mod mgmt_token;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
#[path = "windows/monitor_devnode.rs"]
|
||||||
|
mod monitor_devnode;
|
||||||
mod native_pairing;
|
mod native_pairing;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
mod punktfunk1;
|
mod punktfunk1;
|
||||||
mod pwinit;
|
mod pwinit;
|
||||||
|
mod send_pacing;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
#[path = "windows/service.rs"]
|
#[path = "windows/service.rs"]
|
||||||
mod service;
|
mod service;
|
||||||
@@ -194,6 +202,11 @@ fn real_main() -> Result<()> {
|
|||||||
// driver to a stray second host started while the service sat idle.
|
// driver to a stray second host started while the service sat idle.
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
vdisplay::manager::claim_instance_eagerly();
|
vdisplay::manager::claim_instance_eagerly();
|
||||||
|
// Crash recovery for the experimental `pnp_disable_monitors` axis: re-enable any
|
||||||
|
// monitor devnodes a previous host disabled for an Exclusive session and never
|
||||||
|
// restored (crash/kill/power loss) — before any new session touches the topology.
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
monitor_devnode::startup_recover();
|
||||||
gamestream::serve(mgmt_opts, native, gamestream)
|
gamestream::serve(mgmt_opts, native, gamestream)
|
||||||
}
|
}
|
||||||
// Print the management API's OpenAPI document (for client codegen).
|
// Print the management API's OpenAPI document (for client codegen).
|
||||||
@@ -213,9 +226,9 @@ fn real_main() -> Result<()> {
|
|||||||
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
// Zero-copy FFI/GPU probe: init the EGL importer + CUDA context (no capture needed).
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-probe") => zerocopy::probe(),
|
Some("zerocopy-probe") => zerocopy::probe(),
|
||||||
// Hidden: the isolated GPU-import worker the capture path spawns from /proc/self/exe
|
// Hidden: the isolated GPU-import worker the capture path spawns from a pinned fd to its
|
||||||
// (design/zerocopy-worker-isolation.md) — never run by hand; --fd names the inherited
|
// own executable image (design/zerocopy-worker-isolation.md) — never run by hand; --fd
|
||||||
// socketpair end.
|
// names the inherited socketpair end.
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
Some("zerocopy-worker") => zerocopy::worker::run_from_args(&args[1..]),
|
||||||
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
// NV12 colour self-test (no display/capture needed): convert a known RGBA pattern to NV12
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! Detector for METRONOMIC event cycles — evenly-spaced disturbances repeating every few seconds.
|
||||||
|
//!
|
||||||
|
//! The "periodic double-jolt" symptom class field reports keep describing is a host/display-side
|
||||||
|
//! disturbance on a stable multi-second period (display-topology churn, display-poller software,
|
||||||
|
//! virtual-display present timing). Random network loss is bursty and irregular; a stable period is
|
||||||
|
//! a machine, and saying so in the host log turns a "nothing in the logs :/" report into a
|
||||||
|
//! self-diagnosis. Two feeds today: served client-recovery IDRs (`punktfunk1`) and IDD-push capture
|
||||||
|
//! stalls (`capture::windows::idd_push`).
|
||||||
|
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Pure evenly-spaced-events detector (unit-tested below).
|
||||||
|
///
|
||||||
|
/// Events within [`Self::COALESCE`] count as ONE (a double-jolt's paired disturbances — e.g. the
|
||||||
|
/// cooldown re-issue of a lost keyframe ~0.7 s after the first — are one user-visible cycle). When
|
||||||
|
/// the gaps between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their
|
||||||
|
/// mean, [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
||||||
|
/// [`Self::REWARN`] while the cycle persists.
|
||||||
|
pub(crate) struct Metronome {
|
||||||
|
events: VecDeque<Instant>,
|
||||||
|
last_warn: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metronome {
|
||||||
|
/// Events closer together than this are the same user-visible disturbance.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(1500);
|
||||||
|
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
|
||||||
|
const STREAK: usize = 4;
|
||||||
|
/// "Evenly spaced" = every gap within this fraction of the mean gap.
|
||||||
|
const TOLERANCE: f64 = 0.2;
|
||||||
|
/// Once warned, re-warn at most this often while the cycle persists.
|
||||||
|
const REWARN: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
|
pub(crate) fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
events: VecDeque::new(),
|
||||||
|
last_warn: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record a disturbance at `now`; `Some(mean period)` exactly when the metronomic-cycle
|
||||||
|
/// warning should fire.
|
||||||
|
pub(crate) fn note(&mut self, now: Instant) -> Option<Duration> {
|
||||||
|
if self
|
||||||
|
.events
|
||||||
|
.back()
|
||||||
|
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.events.push_back(now);
|
||||||
|
if self.events.len() > Self::STREAK {
|
||||||
|
self.events.pop_front();
|
||||||
|
}
|
||||||
|
if self.events.len() < Self::STREAK {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let gaps: Vec<f64> = self
|
||||||
|
.events
|
||||||
|
.iter()
|
||||||
|
.zip(self.events.iter().skip(1))
|
||||||
|
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
|
||||||
|
.collect();
|
||||||
|
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
|
||||||
|
if mean <= 0.0
|
||||||
|
|| gaps
|
||||||
|
.iter()
|
||||||
|
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if self
|
||||||
|
.last_warn
|
||||||
|
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.last_warn = Some(now);
|
||||||
|
Some(Duration::from_secs_f64(mean))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Feed a [`Metronome`] a schedule of event offsets (ms from a common origin) and return
|
||||||
|
/// what each `note` produced.
|
||||||
|
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<Duration>> {
|
||||||
|
let base = Instant::now();
|
||||||
|
let mut c = Metronome::new();
|
||||||
|
offsets_ms
|
||||||
|
.iter()
|
||||||
|
.map(|ms| c.note(base + Duration::from_millis(*ms)))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_detects_metronomic_events() {
|
||||||
|
// Four events ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
|
||||||
|
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
|
||||||
|
assert_eq!(out[..3], [None, None, None]);
|
||||||
|
let period = out[3].expect("metronomic series must be detected");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 3.98).abs() < 0.2,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_coalesces_double_jolt_pairs() {
|
||||||
|
// The field signature: a jolt pair (second event ~0.7 s after the first, e.g. the IDR
|
||||||
|
// cooldown re-issue) every ~4 s. Each pair is ONE event; detection still lands on the
|
||||||
|
// ~4 s cycle.
|
||||||
|
let out = cadence_run(&[
|
||||||
|
0, 700, // pair 1
|
||||||
|
4_000, 4_700, // pair 2
|
||||||
|
8_000, 8_650, // pair 3
|
||||||
|
12_000, // pair 4 (first event trips it)
|
||||||
|
]);
|
||||||
|
assert!(out[..6].iter().all(Option::is_none));
|
||||||
|
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
|
||||||
|
assert!(
|
||||||
|
(period.as_secs_f64() - 4.0).abs() < 0.2,
|
||||||
|
"period={period:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_ignores_irregular_bursts() {
|
||||||
|
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
|
||||||
|
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
|
||||||
|
.iter()
|
||||||
|
.all(Option::is_none));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cadence_rewarns_at_most_every_30s() {
|
||||||
|
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
|
||||||
|
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
|
||||||
|
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
|
||||||
|
let out = cadence_run(&offsets);
|
||||||
|
let warned: Vec<usize> = out
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, o)| o.map(|_| i))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(warned, vec![3, 11], "warn indices");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1051,6 +1051,10 @@ fn display_settings_state() -> DisplaySettingsState {
|
|||||||
"identity".into(),
|
"identity".into(),
|
||||||
"layout".into(),
|
"layout".into(),
|
||||||
"game_session".into(),
|
"game_session".into(),
|
||||||
|
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
|
||||||
|
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
|
||||||
|
"ddc_power_off".into(),
|
||||||
|
"pnp_disable_monitors".into(),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1256,10 +1260,12 @@ async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Resp
|
|||||||
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
|
||||||
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
|
||||||
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
|
||||||
let policy = store
|
let policy = store.get().effective().with_manual_layout(
|
||||||
.get()
|
req.positions,
|
||||||
.effective()
|
store.game_session(),
|
||||||
.with_manual_layout(req.positions, store.game_session());
|
store.ddc_power_off(),
|
||||||
|
store.pnp_disable_monitors(),
|
||||||
|
);
|
||||||
if let Err(e) = store.set(policy) {
|
if let Err(e) = store.set(policy) {
|
||||||
return api_error(
|
return api_error(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -2944,6 +2950,9 @@ mod tests {
|
|||||||
assert!(enforced.contains(&"mode_conflict"));
|
assert!(enforced.contains(&"mode_conflict"));
|
||||||
assert!(enforced.contains(&"identity"));
|
assert!(enforced.contains(&"identity"));
|
||||||
assert!(enforced.contains(&"layout"));
|
assert!(enforced.contains(&"layout"));
|
||||||
|
// The experimental DDC/CI + PnP-disable axes are acted on (Windows exclusive-isolate path).
|
||||||
|
assert!(enforced.contains(&"ddc_power_off"));
|
||||||
|
assert!(enforced.contains(&"pnp_disable_monitors"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSoc
|
|||||||
|
|
||||||
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
|
||||||
use crate::native_pairing::{NativePairing, PairingDecision};
|
use crate::native_pairing::{NativePairing, PairingDecision};
|
||||||
|
use crate::send_pacing::{percentile, PaceStat};
|
||||||
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
|
/// The shared streaming-stats recorder (web-console capture/graph), shared with the management API
|
||||||
/// and the GameStream loop; threaded into each session's `SessionContext`.
|
/// and the GameStream loop; threaded into each session's `SessionContext`.
|
||||||
use crate::stats_recorder::StatsRecorder;
|
use crate::stats_recorder::StatsRecorder;
|
||||||
@@ -978,6 +979,19 @@ async fn serve_session(
|
|||||||
"encode chroma"
|
"encode chroma"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Linux 4:4:4 rides the CPU swscale → 8-bit `YUV444P` path (see `encode/linux`) — there
|
||||||
|
// is no 10-bit 4:4:4 input there, so a 10-bit-negotiated session would silently encode
|
||||||
|
// 8-bit. Resolve the depth DOWN before the Welcome so the wire never overstates what the
|
||||||
|
// stream carries. (Windows NVENC composes Main 4:4:4 10 from an RGB input, so it keeps
|
||||||
|
// the resolved depth — this clamp is Linux-only.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
let bit_depth: u8 = if chroma.is_444() && bit_depth == 10 {
|
||||||
|
tracing::info!("4:4:4 on the Linux path encodes 8-bit YUV444P — resolving bit depth 8");
|
||||||
|
8
|
||||||
|
} else {
|
||||||
|
bit_depth
|
||||||
|
};
|
||||||
|
|
||||||
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
|
||||||
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
|
||||||
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
|
||||||
@@ -987,6 +1001,13 @@ async fn serve_session(
|
|||||||
|
|
||||||
let mut key = [0u8; 16];
|
let mut key = [0u8; 16];
|
||||||
rand::thread_rng().fill_bytes(&mut key);
|
rand::thread_rng().fill_bytes(&mut key);
|
||||||
|
// Fresh per-session salt alongside the fresh key. GCM nonce uniqueness only *requires* one
|
||||||
|
// of the two to be unique per session (the nonce is salt || sequence under the session
|
||||||
|
// key), but a constant salt would make a key-reuse bug catastrophic instead of merely
|
||||||
|
// wrong — this keeps the second line of defense real. Negotiated via Welcome, so clients
|
||||||
|
// just follow.
|
||||||
|
let mut salt = [0u8; 4];
|
||||||
|
rand::thread_rng().fill_bytes(&mut salt);
|
||||||
let welcome = Welcome {
|
let welcome = Welcome {
|
||||||
abi_version: punktfunk_core::WIRE_VERSION,
|
abi_version: punktfunk_core::WIRE_VERSION,
|
||||||
udp_port,
|
udp_port,
|
||||||
@@ -1012,7 +1033,7 @@ async fn serve_session(
|
|||||||
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
shard_payload: mtu1500_shard_payload_for(peer.ip()) as u16,
|
||||||
encrypt: true,
|
encrypt: true,
|
||||||
key,
|
key,
|
||||||
salt: *b"pkf1",
|
salt,
|
||||||
frames: match source {
|
frames: match source {
|
||||||
Punktfunk1Source::Synthetic => frames,
|
Punktfunk1Source::Synthetic => frames,
|
||||||
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
|
Punktfunk1Source::Virtual => 0, // unbounded — client streams until we close
|
||||||
@@ -2617,34 +2638,16 @@ fn service_probes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seal one access unit and send its packets PACED over the budget until `deadline` (the next
|
/// Seal one access unit and send it with MICROBURST pacing (the shared
|
||||||
/// frame's due time), in 16-packet `sendmmsg` chunks — so a high-bitrate frame spreads across the
|
/// [`send_pacing`](crate::send_pacing) policy, native parameterization): the first `burst_cap`
|
||||||
/// frame interval instead of bursting all at once into the NIC. A real link drops a line-rate burst
|
/// bytes go out immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and
|
||||||
/// (the host send buffer EAGAINs), and under infinite GOP a single dropped frame freezes the decode
|
/// only the OVERFLOW beyond that is spread in 16-packet chunks across ~90% of the time to
|
||||||
/// until the next keyframe — the cause of the "freezes over ~150 Mbps, no image at 400 Mbps"
|
/// `deadline`. So a normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added
|
||||||
/// symptom. When there's little/no slack (encode ≈ interval at very high fps) the budget collapses
|
/// latency, while a genuine IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping
|
||||||
/// to ~0 and every chunk goes out immediately, so this is never slower than the unpaced path.
|
/// the freeze fix exactly where it's needed (an unpaced line-rate burst overruns the kernel tx
|
||||||
/// One paced send's outcome: how long the frame's packets took to leave (`spread_us`) and whether
|
/// buffer → EAGAIN drop → under infinite GOP, a freeze until the next keyframe). With no slack
|
||||||
/// any were paced (vs the whole frame fitting the microburst and going out immediately). Fed to the
|
/// (encode ≈ interval) the budget collapses to 0 and even the overflow goes out immediately, so
|
||||||
/// PUNKTFUNK_PERF histogram so the pacing tail is visible per-frame.
|
/// this is never slower than unpaced.
|
||||||
struct PaceStat {
|
|
||||||
spread_us: u32,
|
|
||||||
paced: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
const PACE_CHUNK: usize = 16;
|
|
||||||
|
|
||||||
/// Seal one access unit and send it with MICROBURST pacing: the first `burst_cap` bytes go out
|
|
||||||
/// immediately (one absorbed burst the NIC / socket tx-buffer can swallow), and only the OVERFLOW
|
|
||||||
/// beyond that is spread in [`PACE_CHUNK`]-packet chunks across ~90% of the time to `deadline`. So a
|
|
||||||
/// normal-bitrate frame (≤ cap) leaves in one immediate burst at ~0 added latency, while a genuine
|
|
||||||
/// IDR / sustained-high-bitrate frame (≫ cap) still spreads — keeping the freeze fix exactly where
|
|
||||||
/// it's needed (an unpaced line-rate burst overruns the kernel tx buffer → EAGAIN drop → under
|
|
||||||
/// infinite GOP, a freeze until the next keyframe). With no slack (encode ≈ interval) the budget
|
|
||||||
/// collapses to 0 and even the overflow goes out immediately, so this is never slower than unpaced.
|
|
||||||
/// Parsed-once `PUNKTFUNK_VIDEO_DROP` percentage for the native data plane (see `paced_submit`).
|
|
||||||
static NATIVE_VIDEO_DROP: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
|
|
||||||
|
|
||||||
fn paced_submit(
|
fn paced_submit(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
@@ -2657,80 +2660,25 @@ fn paced_submit(
|
|||||||
.seal_frame(data, pts_ns, flags)
|
.seal_frame(data, pts_ns, flags)
|
||||||
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
|
.map_err(|e| anyhow!("seal_frame: {e:?}"))?;
|
||||||
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
let mut refs: Vec<&[u8]> = wires.iter().map(|w| w.as_slice()).collect();
|
||||||
// FEC/recovery test knob: PUNKTFUNK_VIDEO_DROP=N discards N% of the sealed wire packets
|
// FEC/recovery test knob (PUNKTFUNK_VIDEO_DROP) — same knob the GameStream plane honors.
|
||||||
// before send — controlled loss injection with no netem/root, same knob the GameStream video
|
crate::send_pacing::inject_video_drop(&mut refs);
|
||||||
// path honors. Parsed once; 0/unset = off (the normal path is untouched).
|
let cfg = crate::send_pacing::PaceCfg {
|
||||||
let drop_pct = *NATIVE_VIDEO_DROP.get_or_init(|| {
|
burst_bytes: Some(burst_cap),
|
||||||
let pct = std::env::var("PUNKTFUNK_VIDEO_DROP")
|
chunk: crate::send_pacing::ChunkPolicy::Fixed(16),
|
||||||
.ok()
|
sleep_floor: std::time::Duration::from_micros(500),
|
||||||
.and_then(|s| s.parse::<u32>().ok())
|
};
|
||||||
.filter(|p| (1..=90).contains(p))
|
let result = crate::send_pacing::pace_frame(
|
||||||
.unwrap_or(0);
|
&refs,
|
||||||
if pct > 0 {
|
crate::send_pacing::PaceBudget::UntilDeadline {
|
||||||
tracing::warn!(
|
deadline,
|
||||||
pct,
|
fraction: 0.9,
|
||||||
"PUNKTFUNK_VIDEO_DROP: injecting wire-packet loss (FEC test)"
|
},
|
||||||
|
&cfg,
|
||||||
|
|chunk| session.send_sealed(chunk).map(|_| ()),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
pct
|
|
||||||
});
|
|
||||||
if drop_pct > 0 {
|
|
||||||
use rand::Rng;
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
refs.retain(|_| rng.gen_range(0..100) >= drop_pct);
|
|
||||||
}
|
|
||||||
let start = std::time::Instant::now();
|
|
||||||
|
|
||||||
// Split at the microburst cap: packets [0..split] burst out immediately, [split..] are paced.
|
|
||||||
let mut cum = 0usize;
|
|
||||||
let mut split = refs.len();
|
|
||||||
for (k, r) in refs.iter().enumerate() {
|
|
||||||
cum += r.len();
|
|
||||||
if cum >= burst_cap {
|
|
||||||
split = k + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for chunk in refs[..split].chunks(PACE_CHUNK) {
|
|
||||||
session
|
|
||||||
.send_sealed(chunk)
|
|
||||||
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
|
|
||||||
}
|
|
||||||
let paced = split < refs.len();
|
|
||||||
if paced {
|
|
||||||
let pace_start = std::time::Instant::now();
|
|
||||||
let budget = deadline
|
|
||||||
.checked_duration_since(pace_start)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.mul_f32(0.9);
|
|
||||||
let m = refs[split..].len().div_ceil(PACE_CHUNK).max(1);
|
|
||||||
for (j, chunk) in refs[split..].chunks(PACE_CHUNK).enumerate() {
|
|
||||||
session
|
|
||||||
.send_sealed(chunk)
|
|
||||||
.map_err(|e| anyhow!("send_sealed: {e:?}"))?;
|
|
||||||
// Sleep toward this chunk's slice of the budget; skip sub-500µs waits (scheduler jitter).
|
|
||||||
let target = pace_start + budget.mul_f64((j + 1) as f64 / m as f64);
|
|
||||||
if let Some(ahead) = target.checked_duration_since(std::time::Instant::now()) {
|
|
||||||
if ahead > std::time::Duration::from_micros(500) {
|
|
||||||
std::thread::sleep(ahead);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let spread_us = start.elapsed().as_micros() as u32;
|
|
||||||
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
drop(refs); // release the borrow of `wires` so it can return to the seal pool
|
||||||
session.reclaim_wires(wires);
|
session.reclaim_wires(wires);
|
||||||
Ok(PaceStat { spread_us, paced })
|
result.map_err(|e| anyhow!("send_sealed: {e:?}"))
|
||||||
}
|
|
||||||
|
|
||||||
/// Percentile of a slice (sorts it in place first). `q` in 0.0..=1.0.
|
|
||||||
fn percentile(sorted_or_not: &mut [u32], q: f64) -> u32 {
|
|
||||||
if sorted_or_not.is_empty() {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
sorted_or_not.sort_unstable();
|
|
||||||
let i = ((sorted_or_not.len() as f64 * q) as usize).min(sorted_or_not.len() - 1);
|
|
||||||
sorted_or_not[i]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One encoded frame handed from the capture/encode thread to the send thread (the encode|send
|
/// One encoded frame handed from the capture/encode thread to the send thread (the encode|send
|
||||||
@@ -3230,82 +3178,6 @@ struct SessionContext {
|
|||||||
launch: Option<String>,
|
launch: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detector for METRONOMIC client keyframe-recovery cycles — the "periodic double-jolt" symptom
|
|
||||||
/// class field reports keep describing: a host/display-side disturbance repeating every few
|
|
||||||
/// seconds (display-topology churn, display-poller software, virtual-display timing), where each
|
|
||||||
/// cycle ends in a client keyframe request the host serves. Random network loss is bursty and
|
|
||||||
/// irregular; a stable period is a machine, and saying so in the host log turns a "nothing in the
|
|
||||||
/// logs :/" report into a self-diagnosis.
|
|
||||||
///
|
|
||||||
/// Served forced IDRs within [`Self::COALESCE`] count as ONE event (a double-jolt's paired IDRs —
|
|
||||||
/// the cooldown re-issue of a lost keyframe — are one user-visible disturbance). When the gaps
|
|
||||||
/// between the last [`Self::STREAK`] events are all within ±[`Self::TOLERANCE`] of their mean,
|
|
||||||
/// [`Self::note`] returns the mean period for the caller to warn with, then stays quiet for
|
|
||||||
/// [`Self::REWARN`] while the cycle persists. Pure logic — unit-tested below.
|
|
||||||
struct RecoveryCadence {
|
|
||||||
events: std::collections::VecDeque<std::time::Instant>,
|
|
||||||
last_warn: Option<std::time::Instant>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecoveryCadence {
|
|
||||||
/// Serves closer together than this are the same user-visible disturbance.
|
|
||||||
const COALESCE: std::time::Duration = std::time::Duration::from_millis(1500);
|
|
||||||
/// Consecutive evenly-spaced events before the cycle counts as metronomic.
|
|
||||||
const STREAK: usize = 4;
|
|
||||||
/// "Evenly spaced" = every gap within this fraction of the mean gap.
|
|
||||||
const TOLERANCE: f64 = 0.2;
|
|
||||||
/// Once warned, re-warn at most this often while the cycle persists.
|
|
||||||
const REWARN: std::time::Duration = std::time::Duration::from_secs(30);
|
|
||||||
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
events: std::collections::VecDeque::new(),
|
|
||||||
last_warn: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a served client-recovery IDR at `now`; `Some(mean period)` exactly when the
|
|
||||||
/// metronomic-cycle warning should fire.
|
|
||||||
fn note(&mut self, now: std::time::Instant) -> Option<std::time::Duration> {
|
|
||||||
if self
|
|
||||||
.events
|
|
||||||
.back()
|
|
||||||
.is_some_and(|last| now.duration_since(*last) < Self::COALESCE)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.events.push_back(now);
|
|
||||||
if self.events.len() > Self::STREAK {
|
|
||||||
self.events.pop_front();
|
|
||||||
}
|
|
||||||
if self.events.len() < Self::STREAK {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let gaps: Vec<f64> = self
|
|
||||||
.events
|
|
||||||
.iter()
|
|
||||||
.zip(self.events.iter().skip(1))
|
|
||||||
.map(|(a, b)| b.duration_since(*a).as_secs_f64())
|
|
||||||
.collect();
|
|
||||||
let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
|
|
||||||
if mean <= 0.0
|
|
||||||
|| gaps
|
|
||||||
.iter()
|
|
||||||
.any(|g| (g - mean).abs() > mean * Self::TOLERANCE)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if self
|
|
||||||
.last_warn
|
|
||||||
.is_some_and(|t| now.duration_since(t) < Self::REWARN)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.last_warn = Some(now);
|
|
||||||
Some(std::time::Duration::from_secs_f64(mean))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
||||||
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
// This thread runs the capture+encode loop (single-process — the only topology: Linux portal /
|
||||||
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
// synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU
|
||||||
@@ -3521,8 +3393,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
// opening GOP, instead of answering it with a redundant second IDR.
|
// opening GOP, instead of answering it with a redundant second IDR.
|
||||||
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
|
let mut last_forced_idr: Option<std::time::Instant> = Some(std::time::Instant::now());
|
||||||
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
|
// Self-diagnosis for the periodic-stutter class: warns when the served recovery IDRs settle
|
||||||
// into a stable multi-second rhythm (see [`RecoveryCadence`]).
|
// into a stable multi-second rhythm (see [`crate::metronome::Metronome`]).
|
||||||
let mut recovery_cadence = RecoveryCadence::new();
|
let mut recovery_cadence = crate::metronome::Metronome::new();
|
||||||
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
// Per-stage latency breakdown (PUNKTFUNK_PERF): per-call µs for the GPU-bound stages so we see
|
||||||
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
// exactly where the capture→encoded latency goes — cap=try_latest (ring read + colour convert),
|
||||||
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
// submit=encode_picture launch, wait=lock_bitstream (the scheduling wait + ASIC encode, the one
|
||||||
@@ -3733,7 +3605,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
|
|||||||
disturbance (display-topology churn, display-poller software, \
|
disturbance (display-topology churn, display-poller software, \
|
||||||
virtual-display timing) is the likely cause, not random network loss; \
|
virtual-display timing) is the likely cause, not random network loss; \
|
||||||
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
correlate with 'slow display-descriptor poll' / 'display descriptor \
|
||||||
changed' lines"
|
changed' / 'IDD-push capture stall' lines"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4425,69 +4297,6 @@ mod tests {
|
|||||||
assert_eq!(dec, snap);
|
assert_eq!(dec, snap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Feed [`RecoveryCadence`] a schedule of event offsets (ms from a common origin) and return
|
|
||||||
/// what each `note` produced.
|
|
||||||
fn cadence_run(offsets_ms: &[u64]) -> Vec<Option<std::time::Duration>> {
|
|
||||||
let base = std::time::Instant::now();
|
|
||||||
let mut c = RecoveryCadence::new();
|
|
||||||
offsets_ms
|
|
||||||
.iter()
|
|
||||||
.map(|ms| c.note(base + std::time::Duration::from_millis(*ms)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_detects_metronomic_recoveries() {
|
|
||||||
// Four IDR serves ~4 s apart (±5%) → the fourth trips the detector at ~4 s.
|
|
||||||
let out = cadence_run(&[0, 4_000, 8_100, 11_950]);
|
|
||||||
assert_eq!(out[..3], [None, None, None]);
|
|
||||||
let period = out[3].expect("metronomic series must be detected");
|
|
||||||
assert!(
|
|
||||||
(period.as_secs_f64() - 3.98).abs() < 0.2,
|
|
||||||
"period={period:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_coalesces_double_jolt_pairs() {
|
|
||||||
// The field signature: a jolt pair (second IDR ~0.7 s after the first, the cooldown
|
|
||||||
// re-issue) every ~4 s. Each pair is ONE event; detection still lands on the ~4 s cycle.
|
|
||||||
let out = cadence_run(&[
|
|
||||||
0, 700, // pair 1
|
|
||||||
4_000, 4_700, // pair 2
|
|
||||||
8_000, 8_650, // pair 3
|
|
||||||
12_000, // pair 4 (first serve trips it)
|
|
||||||
]);
|
|
||||||
assert!(out[..6].iter().all(Option::is_none));
|
|
||||||
let period = out[6].expect("coalesced pairs must still read as a 4 s cycle");
|
|
||||||
assert!(
|
|
||||||
(period.as_secs_f64() - 4.0).abs() < 0.2,
|
|
||||||
"period={period:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_ignores_irregular_bursts() {
|
|
||||||
// Genuine Wi-Fi-style loss: irregular gaps → never flagged.
|
|
||||||
assert!(cadence_run(&[0, 2_000, 9_000, 12_500, 21_000])
|
|
||||||
.iter()
|
|
||||||
.all(Option::is_none));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cadence_rewarns_at_most_every_30s() {
|
|
||||||
// A persisting 4 s cycle: warn on the 4th event (t=12 s), then stay quiet until ≥30 s
|
|
||||||
// past the warn — the t=44 s event (index 11) is the first at or beyond t=42 s.
|
|
||||||
let offsets: Vec<u64> = (0..12).map(|i| i * 4_000).collect();
|
|
||||||
let out = cadence_run(&offsets);
|
|
||||||
let warned: Vec<usize> = out
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(i, o)| o.map(|_| i))
|
|
||||||
.collect();
|
|
||||||
assert_eq!(warned, vec![3, 11], "warn indices");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn adapt_fec_maps_loss_to_recovery_band() {
|
fn adapt_fec_maps_loss_to_recovery_band() {
|
||||||
// A perfectly clean window (0 loss) lands on the floor.
|
// A perfectly clean window (0 loss) lands on the floor.
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
//! Shared microburst pacing POLICY for the two video send planes (networking-audit deferred
|
||||||
|
//! plan §5): the native plane (`punktfunk1::paced_submit`, GSO via the core `Session`) and the
|
||||||
|
//! GameStream compat plane (`gamestream::stream::spawn_sender`, `sendmmsg` over its own RTP
|
||||||
|
//! socket). Both spread a frame's packets across a time budget in chunked bursts so a real link
|
||||||
|
//! doesn't drop the frame as one line-rate burst; the syscall layers stay deliberately separate
|
||||||
|
//! (different sockets, framing, and error contracts) — this module shares the schedule, not the
|
||||||
|
//! plumbing.
|
||||||
|
//!
|
||||||
|
//! The two planes keep their historical parameterizations exactly (pinned by the
|
||||||
|
//! deterministic-schedule tests below):
|
||||||
|
//!
|
||||||
|
//! * **native** — the first `burst_bytes` leave immediately (one absorbed microburst), only the
|
||||||
|
//! overflow is paced in fixed 16-packet chunks across 90 % of the time left to the frame
|
||||||
|
//! deadline (no slack ⇒ budget 0 ⇒ never slower than unpaced);
|
||||||
|
//! * **GameStream** — no burst stage; the whole frame spreads across a fixed ¾-frame-interval
|
||||||
|
//! budget in a BOUNDED number of steps (≤ 12, chunk ≥ 16), because on that non-RT send thread
|
||||||
|
//! every step ends in a `thread::sleep` whose overshoot must stay independent of bitrate
|
||||||
|
//! (Moonlight clients are tested against this timing).
|
||||||
|
//!
|
||||||
|
//! `PUNKTFUNK_VIDEO_DROP` (the FEC-recovery test knob both planes honor) and the stats
|
||||||
|
//! `percentile` helper live here too — they were duplicated alongside the pacing.
|
||||||
|
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// One paced send's outcome: how long the frame's packets took to leave (`spread_us`) and
|
||||||
|
/// whether any were paced (vs the whole frame fitting the microburst and going out
|
||||||
|
/// immediately). The native plane feeds it to the PUNKTFUNK_PERF histogram so the pacing tail
|
||||||
|
/// is visible per-frame.
|
||||||
|
pub(crate) struct PaceStat {
|
||||||
|
pub(crate) spread_us: u32,
|
||||||
|
pub(crate) paced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a frame's packets split into send chunks.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) enum ChunkPolicy {
|
||||||
|
/// Fixed chunk size; the step count scales with the frame (native: 16).
|
||||||
|
Fixed(usize),
|
||||||
|
/// Bounded step count: `chunk = max(min_chunk, ceil(n / max_steps))` (GameStream: 16 / 12).
|
||||||
|
/// Keeps per-frame sleep overshoot independent of bitrate — see `spawn_sender`'s history.
|
||||||
|
Bounded { min_chunk: usize, max_steps: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The time the paced (post-burst) packets spread across.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) enum PaceBudget {
|
||||||
|
/// `(deadline − now-after-burst) × fraction`, collapsing to 0 with no slack (native: 0.9).
|
||||||
|
UntilDeadline { deadline: Instant, fraction: f32 },
|
||||||
|
/// A precomputed fixed budget (GameStream: ¾ of the frame interval).
|
||||||
|
Fixed(Duration),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-plane pacing parameters. See the module doc for the two canonical values.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub(crate) struct PaceCfg {
|
||||||
|
/// Bytes that leave immediately as one absorbed microburst before pacing starts; `None` =
|
||||||
|
/// no burst stage at all (GameStream). `Some(0)` still bursts the first packet — the split
|
||||||
|
/// is "the packet that crosses the cap goes with the burst", exactly the native semantics.
|
||||||
|
pub(crate) burst_bytes: Option<usize>,
|
||||||
|
pub(crate) chunk: ChunkPolicy,
|
||||||
|
/// Sleeps shorter than this are skipped (scheduler-jitter floor; both planes: 500 µs).
|
||||||
|
pub(crate) sleep_floor: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A frame's send schedule, computed up front as pure data (what the deterministic tests pin):
|
||||||
|
/// packets `[0..burst_len)` go immediately in `chunk`-sized bursts; the rest go in `steps`
|
||||||
|
/// chunks of `chunk`, chunk `j` (0-based) sleeping toward `budget × (j+1)/steps`.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) struct PaceSchedule {
|
||||||
|
pub(crate) burst_len: usize,
|
||||||
|
pub(crate) chunk: usize,
|
||||||
|
pub(crate) steps: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the schedule for one frame's wire packets under `cfg`.
|
||||||
|
pub(crate) fn schedule<T: AsRef<[u8]>>(packets: &[T], cfg: &PaceCfg) -> PaceSchedule {
|
||||||
|
let burst_len = match cfg.burst_bytes {
|
||||||
|
None => 0,
|
||||||
|
Some(cap) => {
|
||||||
|
// The packet that crosses the cap still bursts (`split = k + 1`) — the whole frame
|
||||||
|
// bursts when it never crosses it.
|
||||||
|
let mut cum = 0usize;
|
||||||
|
let mut split = packets.len();
|
||||||
|
for (k, p) in packets.iter().enumerate() {
|
||||||
|
cum += p.as_ref().len();
|
||||||
|
if cum >= cap {
|
||||||
|
split = k + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
split
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let overflow = packets.len() - burst_len;
|
||||||
|
let (chunk, steps) = match cfg.chunk {
|
||||||
|
ChunkPolicy::Fixed(c) => (c, overflow.div_ceil(c).max(1)),
|
||||||
|
ChunkPolicy::Bounded {
|
||||||
|
min_chunk,
|
||||||
|
max_steps,
|
||||||
|
} => {
|
||||||
|
let c = min_chunk.max(overflow.div_ceil(max_steps));
|
||||||
|
(c, overflow.div_ceil(c).max(1))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
PaceSchedule {
|
||||||
|
burst_len,
|
||||||
|
chunk,
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one frame's packets under the plane's pacing policy: the burst stage leaves
|
||||||
|
/// immediately, then each paced chunk is sent and slept toward its slice of the budget
|
||||||
|
/// (sub-`sleep_floor` waits are skipped). A `send` error aborts the frame and propagates —
|
||||||
|
/// the native plane bails the session, GameStream stops the stream.
|
||||||
|
pub(crate) fn pace_frame<T: AsRef<[u8]>, E>(
|
||||||
|
packets: &[T],
|
||||||
|
budget: PaceBudget,
|
||||||
|
cfg: &PaceCfg,
|
||||||
|
mut send: impl FnMut(&[T]) -> Result<(), E>,
|
||||||
|
) -> Result<PaceStat, E> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let sched = schedule(packets, cfg);
|
||||||
|
for chunk in packets[..sched.burst_len].chunks(sched.chunk) {
|
||||||
|
send(chunk)?;
|
||||||
|
}
|
||||||
|
let paced = sched.burst_len < packets.len();
|
||||||
|
if paced {
|
||||||
|
let pace_start = Instant::now();
|
||||||
|
let budget = match budget {
|
||||||
|
PaceBudget::UntilDeadline { deadline, fraction } => deadline
|
||||||
|
.checked_duration_since(pace_start)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.mul_f32(fraction),
|
||||||
|
PaceBudget::Fixed(d) => d,
|
||||||
|
};
|
||||||
|
for (j, chunk) in packets[sched.burst_len..].chunks(sched.chunk).enumerate() {
|
||||||
|
send(chunk)?;
|
||||||
|
// Sleep toward this chunk's slice of the budget; skip sub-floor waits (jitter).
|
||||||
|
let target = pace_start + budget.mul_f64((j + 1) as f64 / sched.steps as f64);
|
||||||
|
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
|
||||||
|
if ahead >= cfg.sleep_floor {
|
||||||
|
std::thread::sleep(ahead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(PaceStat {
|
||||||
|
spread_us: start.elapsed().as_micros() as u32,
|
||||||
|
paced,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed-once `PUNKTFUNK_VIDEO_DROP` percentage (1..=90, anything else = off): discard N % of
|
||||||
|
/// the sealed wire packets before send — controlled loss injection with no netem/root, honored
|
||||||
|
/// by BOTH video planes. Warned once on activation.
|
||||||
|
pub(crate) fn video_drop_pct() -> u32 {
|
||||||
|
static PCT: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
|
||||||
|
*PCT.get_or_init(|| {
|
||||||
|
let pct = std::env::var("PUNKTFUNK_VIDEO_DROP")
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse::<u32>().ok())
|
||||||
|
.filter(|p| (1..=90).contains(p))
|
||||||
|
.unwrap_or(0);
|
||||||
|
if pct > 0 {
|
||||||
|
tracing::warn!(
|
||||||
|
pct,
|
||||||
|
"PUNKTFUNK_VIDEO_DROP: injecting wire-packet loss (FEC test)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
pct
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the [`video_drop_pct`] loss injection to one frame's wire packets, returning how many
|
||||||
|
/// were discarded (0 when the knob is off — the normal path is untouched).
|
||||||
|
pub(crate) fn inject_video_drop<T>(packets: &mut Vec<T>) -> u64 {
|
||||||
|
let pct = video_drop_pct();
|
||||||
|
if pct == 0 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let before = packets.len();
|
||||||
|
packets.retain(|_| rng.gen_range(0..100) >= pct);
|
||||||
|
(before - packets.len()) as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percentile of a slice (sorts it in place first). `q` in `0.0..=1.0`. Used for the
|
||||||
|
/// PUNKTFUNK_PERF histograms and the web-console stats sample's per-stage p50/p99.
|
||||||
|
pub(crate) fn percentile(v: &mut [u32], q: f64) -> u32 {
|
||||||
|
if v.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
v.sort_unstable();
|
||||||
|
let i = ((v.len() as f64 * q) as usize).min(v.len() - 1);
|
||||||
|
v[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The native plane's canonical parameters (mirrors `punktfunk1::paced_submit`).
|
||||||
|
fn native_cfg(burst_cap: usize) -> PaceCfg {
|
||||||
|
PaceCfg {
|
||||||
|
burst_bytes: Some(burst_cap),
|
||||||
|
chunk: ChunkPolicy::Fixed(16),
|
||||||
|
sleep_floor: Duration::from_micros(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GameStream plane's canonical parameters (mirrors `gamestream::stream::spawn_sender`).
|
||||||
|
fn gs_cfg() -> PaceCfg {
|
||||||
|
PaceCfg {
|
||||||
|
burst_bytes: None,
|
||||||
|
chunk: ChunkPolicy::Bounded {
|
||||||
|
min_chunk: 16,
|
||||||
|
max_steps: 12,
|
||||||
|
},
|
||||||
|
sleep_floor: Duration::from_micros(500),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn packets(n: usize, len: usize) -> Vec<Vec<u8>> {
|
||||||
|
(0..n).map(|_| vec![0u8; len]).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic-schedule pin, native plane: burst split + chunking + step count must
|
||||||
|
/// reproduce the legacy `paced_submit` math exactly — `split = first k with cum ≥ cap + 1`
|
||||||
|
/// (whole frame if never crossed), fixed 16-packet chunks, `m = ceil(overflow/16).max(1)`.
|
||||||
|
#[test]
|
||||||
|
fn native_schedule_matches_legacy_paced_submit() {
|
||||||
|
let legacy = |sizes: &[usize], burst_cap: usize| -> (usize, usize) {
|
||||||
|
// Verbatim transcription of the pre-dedup split + step-count computation.
|
||||||
|
let mut cum = 0usize;
|
||||||
|
let mut split = sizes.len();
|
||||||
|
for (k, len) in sizes.iter().enumerate() {
|
||||||
|
cum += len;
|
||||||
|
if cum >= burst_cap {
|
||||||
|
split = k + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let m = (sizes.len() - split).div_ceil(16).max(1);
|
||||||
|
(split, m)
|
||||||
|
};
|
||||||
|
for (n, len, cap) in [
|
||||||
|
(1usize, 1200usize, 128 * 1024usize), // tiny frame ≪ cap → all burst
|
||||||
|
(109, 1200, 128 * 1024), // exactly at the cap boundary region
|
||||||
|
(110, 1200, 128 * 1024), // one past
|
||||||
|
(600, 1200, 128 * 1024), // 4K P-frame: burst + paced overflow
|
||||||
|
(3300, 1200, 128 * 1024), // multi-MB IDR
|
||||||
|
(600, 1200, 0), // cap 0: first packet still bursts
|
||||||
|
(0, 1200, 128 * 1024), // empty (post-drop-injection) frame
|
||||||
|
] {
|
||||||
|
let pkts = packets(n, len);
|
||||||
|
let sizes: Vec<usize> = pkts.iter().map(|p| p.len()).collect();
|
||||||
|
let (split, m) = legacy(&sizes, cap);
|
||||||
|
let s = schedule(&pkts, &native_cfg(cap));
|
||||||
|
assert_eq!(s.burst_len, split, "n={n} cap={cap}: burst split");
|
||||||
|
assert_eq!(s.chunk, 16, "n={n} cap={cap}: chunk size");
|
||||||
|
assert_eq!(s.steps, m, "n={n} cap={cap}: paced step count");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic-schedule pin, GameStream plane: no burst stage, and the chunk/step layout
|
||||||
|
/// must reproduce the legacy `pace_layout` exactly (chunk = max(16, ceil(n/12)), ≤ 12
|
||||||
|
/// steps) — including the historical bounds its old unit test asserted.
|
||||||
|
#[test]
|
||||||
|
fn gamestream_schedule_matches_legacy_pace_layout() {
|
||||||
|
let legacy_pace_layout = |n: usize| -> (usize, usize) {
|
||||||
|
let chunk_sz = 16usize.max(n.div_ceil(12));
|
||||||
|
(chunk_sz, n.div_ceil(chunk_sz))
|
||||||
|
};
|
||||||
|
for &n in &[1usize, 16, 17, 146, 192, 193, 610, 1024, 5000, 50_000] {
|
||||||
|
let pkts = packets(n, 1024);
|
||||||
|
let (chunk, steps) = legacy_pace_layout(n);
|
||||||
|
let s = schedule(&pkts, &gs_cfg());
|
||||||
|
assert_eq!(s.burst_len, 0, "n={n}: GameStream has no burst stage");
|
||||||
|
assert_eq!(s.chunk, chunk, "n={n}: chunk size");
|
||||||
|
assert_eq!(s.steps, steps, "n={n}: step count");
|
||||||
|
assert!(s.steps <= 12, "n={n}: step count bounded");
|
||||||
|
assert!(s.chunk >= 16, "n={n}: chunk floor");
|
||||||
|
assert!(s.chunk * s.steps >= n, "n={n}: layout covers all packets");
|
||||||
|
}
|
||||||
|
// The legacy test's exact anchors.
|
||||||
|
let s = schedule(&packets(1, 1024), &gs_cfg());
|
||||||
|
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||||
|
let s = schedule(&packets(16, 1024), &gs_cfg());
|
||||||
|
assert_eq!((s.chunk, s.steps), (16, 1));
|
||||||
|
assert!(schedule(&packets(610, 1024), &gs_cfg()).steps <= 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The executed chunk sequence follows the schedule exactly, on both parameterizations —
|
||||||
|
/// zero budget, so the test never sleeps.
|
||||||
|
#[test]
|
||||||
|
fn pace_frame_sends_the_scheduled_chunk_sequence() {
|
||||||
|
// Native, 40 × 1 KB with a 10 KB cap: packets 0..=9 burst (cum hits 10 KB at #10),
|
||||||
|
// then 30 overflow → chunks of 16: [10..26), [26..40).
|
||||||
|
let pkts = packets(40, 1024);
|
||||||
|
let mut seen: Vec<usize> = Vec::new();
|
||||||
|
let stat = pace_frame(
|
||||||
|
&pkts,
|
||||||
|
PaceBudget::Fixed(Duration::ZERO),
|
||||||
|
&native_cfg(10 * 1024),
|
||||||
|
|chunk| {
|
||||||
|
seen.push(chunk.len());
|
||||||
|
Ok::<(), std::io::Error>(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seen, vec![10, 16, 14]);
|
||||||
|
assert!(stat.paced);
|
||||||
|
|
||||||
|
// Native, frame under the cap: one immediate burst (chunked at 16), nothing paced.
|
||||||
|
let pkts = packets(20, 100);
|
||||||
|
let mut seen: Vec<usize> = Vec::new();
|
||||||
|
let stat = pace_frame(
|
||||||
|
&pkts,
|
||||||
|
PaceBudget::Fixed(Duration::ZERO),
|
||||||
|
&native_cfg(128 * 1024),
|
||||||
|
|chunk| {
|
||||||
|
seen.push(chunk.len());
|
||||||
|
Ok::<(), std::io::Error>(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seen, vec![16, 4]);
|
||||||
|
assert!(!stat.paced);
|
||||||
|
|
||||||
|
// GameStream, 146 packets: chunk = max(16, ceil(146/12)=13) = 16 → 10 paced chunks.
|
||||||
|
let pkts = packets(146, 1024);
|
||||||
|
let mut seen: Vec<usize> = Vec::new();
|
||||||
|
pace_frame(
|
||||||
|
&pkts,
|
||||||
|
PaceBudget::Fixed(Duration::ZERO),
|
||||||
|
&gs_cfg(),
|
||||||
|
|chunk| {
|
||||||
|
seen.push(chunk.len());
|
||||||
|
Ok::<(), std::io::Error>(())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(seen.len(), 10);
|
||||||
|
assert_eq!(seen.iter().sum::<usize>(), 146);
|
||||||
|
assert!(seen[..9].iter().all(|&c| c == 16));
|
||||||
|
assert_eq!(*seen.last().unwrap(), 2);
|
||||||
|
|
||||||
|
// A send error aborts the frame and propagates.
|
||||||
|
let pkts = packets(64, 1024);
|
||||||
|
let mut calls = 0;
|
||||||
|
let r = pace_frame(
|
||||||
|
&pkts,
|
||||||
|
PaceBudget::Fixed(Duration::ZERO),
|
||||||
|
&gs_cfg(),
|
||||||
|
|_chunk| {
|
||||||
|
calls += 1;
|
||||||
|
if calls == 2 {
|
||||||
|
Err(std::io::Error::other("client gone"))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert!(r.is_err());
|
||||||
|
assert_eq!(calls, 2, "no sends after the failing chunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sleep targets are each paced chunk's fraction of the budget — pinned against the
|
||||||
|
/// legacy formulas of both planes (native: `budget×(j+1)/m` directly; GameStream:
|
||||||
|
/// `(budget×1/steps)×(i+1)`, which agrees to sub-step-count nanoseconds).
|
||||||
|
#[test]
|
||||||
|
fn sleep_targets_match_legacy_formulas() {
|
||||||
|
let budget = Duration::from_micros(12_500); // GS: ¾ of a 60 Hz frame interval
|
||||||
|
for steps in [1usize, 2, 10, 12] {
|
||||||
|
for j in 0..steps {
|
||||||
|
let unified = budget.mul_f64((j + 1) as f64 / steps as f64);
|
||||||
|
// Native legacy: one fused fraction — identical expression.
|
||||||
|
assert_eq!(unified, budget.mul_f64((j + 1) as f64 / steps as f64));
|
||||||
|
// GameStream legacy: per_step rounds to ns first; ≤ steps/2 ns apart.
|
||||||
|
let gs_legacy = budget.mul_f64(1.0 / steps as f64).mul_f64((j + 1) as f64);
|
||||||
|
let diff = unified.abs_diff(gs_legacy);
|
||||||
|
assert!(
|
||||||
|
diff <= Duration::from_nanos(steps as u64),
|
||||||
|
"steps={steps} j={j}: {diff:?} off legacy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inject_video_drop` is a no-op when the knob is off (the default test env).
|
||||||
|
#[test]
|
||||||
|
fn drop_injection_off_by_default() {
|
||||||
|
let mut pkts = packets(100, 64);
|
||||||
|
assert_eq!(inject_video_drop(&mut pkts), 0);
|
||||||
|
assert_eq!(pkts.len(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentile_picks_expected_ranks() {
|
||||||
|
let mut v = vec![90, 10, 50, 70, 30];
|
||||||
|
assert_eq!(percentile(&mut v, 0.0), 10);
|
||||||
|
assert_eq!(percentile(&mut v, 0.5), 50);
|
||||||
|
assert_eq!(percentile(&mut v, 0.99), 90);
|
||||||
|
assert_eq!(percentile(&mut [], 0.5), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -132,6 +132,16 @@ impl SessionPlan {
|
|||||||
let gpu = {
|
let gpu = {
|
||||||
let force_cpu_for_nvenc_444 =
|
let force_cpu_for_nvenc_444 =
|
||||||
self.chroma.is_444() && !crate::encode::linux_zero_copy_is_vaapi();
|
self.chroma.is_444() && !crate::encode::linux_zero_copy_is_vaapi();
|
||||||
|
if gpu && force_cpu_for_nvenc_444 {
|
||||||
|
// Surface the trade loudly: this is the single biggest per-frame cost a 4:4:4
|
||||||
|
// session adds (full-res CPU readback + swscale RGB→YUV444P every frame), and
|
||||||
|
// it looks like an unexplained fps ceiling if you don't know it happened.
|
||||||
|
tracing::warn!(
|
||||||
|
"4:4:4 session on the NVENC path: zero-copy GPU capture DISABLED — every \
|
||||||
|
frame is CPU RGB + swscale RGB→YUV444P; expect a lower fps ceiling than \
|
||||||
|
4:2:0 at this mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
gpu && !force_cpu_for_nvenc_444
|
gpu && !force_cpu_for_nvenc_444
|
||||||
};
|
};
|
||||||
crate::capture::OutputFormat {
|
crate::capture::OutputFormat {
|
||||||
|
|||||||
@@ -224,6 +224,25 @@ pub struct DisplayPolicy {
|
|||||||
/// so existing `display-settings.json` files are untouched.
|
/// so existing `display-settings.json` files are untouched.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub game_session: GameSession,
|
pub game_session: GameSession,
|
||||||
|
/// EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →
|
||||||
|
/// DPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.
|
||||||
|
/// Targets the "connected-but-dark head" periodic-stutter class (monitor standby
|
||||||
|
/// auto-input-scan / DP link churn while the virtual display is the sole active display) at
|
||||||
|
/// the monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in
|
||||||
|
/// the OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across
|
||||||
|
/// preset changes; `#[serde(default)]` = off so existing `display-settings.json` files are
|
||||||
|
/// untouched.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ddc_power_off: bool,
|
||||||
|
/// EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,
|
||||||
|
/// additionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose
|
||||||
|
/// hot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same
|
||||||
|
/// "connected-but-dark head" periodic-stutter class as [`Self::ddc_power_off`], but at the
|
||||||
|
/// Windows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD
|
||||||
|
/// re-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host
|
||||||
|
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pnp_disable_monitors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one() -> u32 {
|
fn one() -> u32 {
|
||||||
@@ -247,6 +266,8 @@ impl Default for DisplayPolicy {
|
|||||||
layout: Layout::default(),
|
layout: Layout::default(),
|
||||||
max_displays: 4,
|
max_displays: 4,
|
||||||
game_session: GameSession::default(),
|
game_session: GameSession::default(),
|
||||||
|
ddc_power_off: false,
|
||||||
|
pnp_disable_monitors: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,6 +327,8 @@ impl EffectivePolicy {
|
|||||||
&self,
|
&self,
|
||||||
positions: BTreeMap<String, Position>,
|
positions: BTreeMap<String, Position>,
|
||||||
game_session: GameSession,
|
game_session: GameSession,
|
||||||
|
ddc_power_off: bool,
|
||||||
|
pnp_disable_monitors: bool,
|
||||||
) -> DisplayPolicy {
|
) -> DisplayPolicy {
|
||||||
DisplayPolicy {
|
DisplayPolicy {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -319,8 +342,10 @@ impl EffectivePolicy {
|
|||||||
positions,
|
positions,
|
||||||
},
|
},
|
||||||
max_displays: self.max_displays,
|
max_displays: self.max_displays,
|
||||||
// Preserve the orthogonal game-session axis (EffectivePolicy doesn't carry it).
|
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
|
||||||
game_session,
|
game_session,
|
||||||
|
ddc_power_off,
|
||||||
|
pnp_disable_monitors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,6 +459,20 @@ impl DisplayPolicyStore {
|
|||||||
self.get().game_session
|
self.get().game_session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The experimental DDC/CI panel-off axis — orthogonal to the preset (like
|
||||||
|
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||||
|
/// unconfigured).
|
||||||
|
pub fn ddc_power_off(&self) -> bool {
|
||||||
|
self.get().ddc_power_off
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The experimental PnP monitor-devnode-disable axis — orthogonal to the preset (like
|
||||||
|
/// [`Self::game_session`]), read directly off the stored policy (default off when
|
||||||
|
/// unconfigured).
|
||||||
|
pub fn pnp_disable_monitors(&self) -> bool {
|
||||||
|
self.get().pnp_disable_monitors
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
|
||||||
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
/// write succeeds, so a full disk can't leave memory and file disagreeing.
|
||||||
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
|
||||||
@@ -749,9 +788,12 @@ mod tests {
|
|||||||
let mut positions = BTreeMap::new();
|
let mut positions = BTreeMap::new();
|
||||||
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
positions.insert("1".to_string(), Position { x: 0, y: 0 });
|
||||||
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
|
||||||
let p = eff.with_manual_layout(positions, GameSession::Dedicated);
|
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
|
||||||
// The orthogonal game-session axis is preserved through the layout transform.
|
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
|
||||||
|
// the transform.
|
||||||
assert_eq!(p.game_session, GameSession::Dedicated);
|
assert_eq!(p.game_session, GameSession::Dedicated);
|
||||||
|
assert!(p.ddc_power_off);
|
||||||
|
assert!(p.pnp_disable_monitors);
|
||||||
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
|
||||||
assert_eq!(p.preset, Preset::Custom);
|
assert_eq!(p.preset, Preset::Custom);
|
||||||
// …every other behavior axis is preserved verbatim…
|
// …every other behavior axis is preserved verbatim…
|
||||||
@@ -776,6 +818,9 @@ mod tests {
|
|||||||
assert_eq!(p.keep_alive, KeepAlive::default());
|
assert_eq!(p.keep_alive, KeepAlive::default());
|
||||||
assert_eq!(p.topology, Topology::Auto);
|
assert_eq!(p.topology, Topology::Auto);
|
||||||
assert_eq!(p.version, 1);
|
assert_eq!(p.version, 1);
|
||||||
|
// A file written before the experimental DDC/PnP axes existed defaults them OFF.
|
||||||
|
assert!(!p.ddc_power_off);
|
||||||
|
assert!(!p.pnp_disable_monitors);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ struct Monitor {
|
|||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
pinger: Option<JoinHandle<()>>,
|
pinger: Option<JoinHandle<()>>,
|
||||||
ccd_saved: Option<SavedConfig>,
|
ccd_saved: Option<SavedConfig>,
|
||||||
|
/// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's
|
||||||
|
/// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0.
|
||||||
|
ddc_panels_off: u32,
|
||||||
|
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled
|
||||||
|
/// at this monitor's isolate — teardown re-enables them BEFORE the CCD restore.
|
||||||
|
pnp_disabled: Vec<String>,
|
||||||
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
/// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op).
|
||||||
gen: u64,
|
gen: u64,
|
||||||
}
|
}
|
||||||
@@ -668,6 +674,8 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut ccd_saved: Option<SavedConfig> = None;
|
let mut ccd_saved: Option<SavedConfig> = None;
|
||||||
|
let mut ddc_panels_off = 0u32;
|
||||||
|
let mut pnp_disabled: Vec<String> = Vec::new();
|
||||||
match &gdi_name {
|
match &gdi_name {
|
||||||
Some(n) => {
|
Some(n) => {
|
||||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||||
@@ -682,10 +690,32 @@ impl VirtualDisplayManager {
|
|||||||
use crate::vdisplay::policy::Topology;
|
use crate::vdisplay::policy::Topology;
|
||||||
match topology_action() {
|
match topology_action() {
|
||||||
Topology::Exclusive => {
|
Topology::Exclusive => {
|
||||||
|
// EXPERIMENTAL `ddc_power_off` policy axis: command the physical panels
|
||||||
|
// dark over DDC/CI BEFORE the isolate — an HMONITOR (and with it the DDC
|
||||||
|
// channel) only exists while the display is still active. A panel that
|
||||||
|
// believes it has an owner skips its no-signal standby probing — the
|
||||||
|
// suspected source of the periodic sole-virtual-display stutter (the
|
||||||
|
// rationale + evidence live in `windows/ddc.rs`).
|
||||||
|
if crate::vdisplay::policy::prefs().ddc_power_off() {
|
||||||
|
ddc_panels_off = crate::ddc::panel_off_except(n);
|
||||||
|
}
|
||||||
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
|
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
|
||||||
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
|
||||||
// crosses), under the `state` lock — the sole topology mutator.
|
// crosses), under the `state` lock — the sole topology mutator.
|
||||||
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
|
||||||
|
// EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took,
|
||||||
|
// additionally disable the deactivated monitors' PnP devnodes (persistent
|
||||||
|
// across hot-plug re-arrival) so a standby monitor/TV's periodic wake
|
||||||
|
// events no longer trigger the Windows reaction cascade — the suspected
|
||||||
|
// hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`).
|
||||||
|
if crate::vdisplay::policy::prefs().pnp_disable_monitors() {
|
||||||
|
if let Some(saved) = &ccd_saved {
|
||||||
|
pnp_disabled = crate::monitor_devnode::disable_for_deactivated(
|
||||||
|
saved,
|
||||||
|
added.target_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Topology::Primary => {
|
Topology::Primary => {
|
||||||
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
// On a headless box the IDD auto-activates as the SOLE display, so a physical
|
||||||
@@ -743,6 +773,8 @@ impl VirtualDisplayManager {
|
|||||||
stop,
|
stop,
|
||||||
pinger: Some(pinger),
|
pinger: Some(pinger),
|
||||||
ccd_saved,
|
ccd_saved,
|
||||||
|
ddc_panels_off,
|
||||||
|
pnp_disabled,
|
||||||
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
gen: self.gen.fetch_add(1, Ordering::Relaxed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -804,7 +836,28 @@ impl VirtualDisplayManager {
|
|||||||
}
|
}
|
||||||
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays.
|
||||||
if let Some(saved) = &mon.ccd_saved {
|
if let Some(saved) = &mon.ccd_saved {
|
||||||
|
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
|
||||||
|
// them re-arrive, so the CCD restore below re-activates paths whose monitors exist
|
||||||
|
// again (a disabled devnode would leave the restored path modeless/EDID-less).
|
||||||
|
if !mon.pnp_disabled.is_empty() {
|
||||||
|
crate::monitor_devnode::enable_instances(&mon.pnp_disabled);
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
}
|
||||||
restore_displays_ccd(saved);
|
restore_displays_ccd(saved);
|
||||||
|
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
|
||||||
|
// returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is
|
||||||
|
// belt-and-braces for the rest. The brief settle wait lets the re-activated paths show
|
||||||
|
// up in EnumDisplayMonitors (no HMONITOR, no DDC channel); teardown is already
|
||||||
|
// seconds-scale and watched by the 10 s wedge logger above.
|
||||||
|
if mon.ddc_panels_off > 0 {
|
||||||
|
thread::sleep(Duration::from_millis(300));
|
||||||
|
let woken = crate::ddc::panel_on_all();
|
||||||
|
tracing::info!(
|
||||||
|
commanded_off = mon.ddc_panels_off,
|
||||||
|
woken,
|
||||||
|
"DDC/CI: panel wake commands sent after topology restore"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
|
// SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and
|
||||||
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
|
// `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user