feat: HDR Step-0 colour-metadata transport + security-audit hardening
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
ci / rust (push) Failing after 45s
apple / swift (push) Successful in 57s
ci / web (push) Successful in 39s
ci / docs-site (push) Successful in 38s
windows-host / package (push) Successful in 3m26s
android / android (push) Successful in 3m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
deb / build-publish (push) Successful in 2m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m22s
decky / build-publish (push) Successful in 25s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 16s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m4s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m7s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 3m5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m37s
flatpak / build-publish (push) Successful in 4m17s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m30s
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 7m53s
Two strands, entangled in punktfunk1.rs, committed together (one builds-green tree). HDR pipeline Step 0 — glass-to-glass colour-metadata transport (docs/hdr-pipeline-plan.md): - Protocol/ABI: ColorInfo on the Welcome + a 0xCE HdrMeta datagram carry the source colour space + HDR10 static mastering metadata (quic.rs, abi.rs connect_ex5 fixing caps=0). - New platform-independent, unit-tested HDR static-metadata helpers (hdr.rs): chromaticities (1/50000), mastering luminance (0.0001 cd/m2), MaxCLL/MaxFALL in HDR10/ST.2086 units. - Capture/encode hooks (capture.rs, encode.rs set_hdr_meta) + Linux client / probe plumbing. Security-audit hardening — top 3 from docs/security-review.md, each adversarially verified: - #1 [HIGH] Secret file permissions. The host key.pem/cert.pem and both trust stores are now written owner-only: 0600 + dir 0700 on Unix (mirrors mgmt_token), best-effort SYSTEM/Administrators/OWNER-only icacls DACL on Windows (%ProgramData% is Users-readable). Closes a local key-disclosure -> host-impersonation gap. New gamestream::{create_private_dir, write_secret_file} + a 0600 regression test. - #2 [HIGH] Native SPAKE2 PIN is single-use. The PIN is consumed the moment the host sends its key-confirmation (which lets the client test its one guess), before reading the proof, so any completed attempt -- right OR wrong -- disarms the window. A wrong PIN isn't observable host-side (the client aborts before sending its proof), so consuming on first attempt is what delivers the documented "one online guess" instead of an unbounded brute-force of the static 4-digit PIN. Test verifies single-use. - #3 [MEDIUM] RTSP packetSize is bounded ([64,2048] in stream_config) and VideoPacketizer::new uses saturating .max(1), killing a PRE-AUTH div-by-zero/underflow panic of the video thread. Tests for {0,15,16,17} + out-of-range rejection. fmt + clippy -D warnings clean; full workspace test suite green (93 host tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -547,6 +547,56 @@ impl PunktfunkHidOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static HDR metadata for an HDR session ([`punktfunk_connection_next_hdr_meta`]): SMPTE ST.2086
|
||||
/// mastering display colour volume + CEA-861.3 content light level. All fields are in the standard
|
||||
/// HDR10 SEI fixed-point units (primaries/white in 1/50000, luminance in 0.0001 cd/m²), ready for
|
||||
/// DXGI `DXGI_HDR_METADATA_HDR10` / Apple `CAEDRMetadata` / Android `KEY_HDR_STATIC_INFO`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PunktfunkHdrMeta {
|
||||
/// Display-primaries x-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
|
||||
pub display_primaries_x: [u16; 3],
|
||||
/// Display-primaries y-chromaticities in 1/50000 units, ST.2086 order [green, blue, red].
|
||||
pub display_primaries_y: [u16; 3],
|
||||
/// White-point x-chromaticity, 1/50000 units.
|
||||
pub white_point_x: u16,
|
||||
/// White-point y-chromaticity, 1/50000 units.
|
||||
pub white_point_y: u16,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[cfg(feature = "quic")]
|
||||
impl PunktfunkHdrMeta {
|
||||
fn from_meta(m: &crate::quic::HdrMeta) -> PunktfunkHdrMeta {
|
||||
PunktfunkHdrMeta {
|
||||
display_primaries_x: [
|
||||
m.display_primaries[0][0],
|
||||
m.display_primaries[1][0],
|
||||
m.display_primaries[2][0],
|
||||
],
|
||||
display_primaries_y: [
|
||||
m.display_primaries[0][1],
|
||||
m.display_primaries[1][1],
|
||||
m.display_primaries[2][1],
|
||||
],
|
||||
white_point_x: m.white_point[0],
|
||||
white_point_y: m.white_point[1],
|
||||
max_display_mastering_luminance: m.max_display_mastering_luminance,
|
||||
min_display_mastering_luminance: m.min_display_mastering_luminance,
|
||||
max_cll: m.max_cll,
|
||||
max_fall: m.max_fall,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PunktfunkRichInput::kind` — a touchpad contact (`finger`/`active`/`x`/`y` valid).
|
||||
pub const PUNKTFUNK_RICH_TOUCHPAD: u8 = 1;
|
||||
/// `PunktfunkRichInput::kind` — a motion sample (`gyro`/`accel` valid).
|
||||
@@ -642,6 +692,20 @@ pub const PUNKTFUNK_GAMEPAD_DUALSENSE: u32 = 2;
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to
|
||||
/// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`.
|
||||
///
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||
/// 10-bit (Main10) HEVC stream. (Mirrors `quic::VIDEO_CAP_10BIT`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
||||
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||
};
|
||||
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
|
||||
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||
@@ -843,6 +907,59 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// Back-compat: ex4 advertises no video caps (8-bit BT.709 SDR). HDR-capable embedders call
|
||||
// `punktfunk_connect_ex5` with the cap bits.
|
||||
unsafe {
|
||||
punktfunk_connect_ex5(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
0,
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex4`], but additionally advertises the embedder's video decode/present
|
||||
/// capabilities as `video_caps` — a bitfield of `PUNKTFUNK_VIDEO_CAP_10BIT` (can decode 10-bit
|
||||
/// Main10) and `PUNKTFUNK_VIDEO_CAP_HDR` (can present BT.2020 PQ HDR10). The host upgrades to a
|
||||
/// 10-bit / HDR encode ONLY when the matching bit is set (and the host opted in); `0` keeps the
|
||||
/// 8-bit BT.709 SDR stream. After connecting, read the resolved colour via
|
||||
/// [`punktfunk_connection_color_info`] and drain the mastering metadata via
|
||||
/// [`punktfunk_connection_next_hdr_meta`].
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`]; `launch_id`, when non-NULL, must be a NUL-terminated C string.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
@@ -891,9 +1008,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex4(
|
||||
pref,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
// 8-bit only over the C ABI for now — the ABI doesn't yet carry the embedder's video
|
||||
// caps (Apple/Android decode 8-bit). The native Windows client advertises 10-bit/HDR.
|
||||
0,
|
||||
video_caps,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -1195,6 +1310,90 @@ pub unsafe extern "C" fn punktfunk_connection_next_hidout(
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level) for
|
||||
/// an HDR session, into `*out`. [`PunktfunkStatus::NoFrame`] on timeout, [`PunktfunkStatus::Closed`]
|
||||
/// once the session ended. The host sends one near session start and re-sends it on mastering
|
||||
/// changes / keyframes; apply the latest to the display (`SetHDRMetaData` / `CAEDRMetadata` /
|
||||
/// `KEY_HDR_STATIC_INFO`). Only an HDR session (`punktfunk_connection_color_info` reports a PQ
|
||||
/// transfer) ever emits these. Same threading rules as [`punktfunk_connection_next_rumble`] (one
|
||||
/// puller, may run alongside the other planes).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable for one `PunktfunkHdrMeta`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_hdr_meta(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkHdrMeta,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_hdr_meta(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(m) => {
|
||||
unsafe { *out = PunktfunkHdrMeta::from_meta(&m) };
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the session's resolved colour signalling + encode bit depth (from the host's Welcome).
|
||||
/// Each out pointer is filled when non-NULL: `primaries`/`transfer`/`matrix` are CICP code points
|
||||
/// (BT.709 = 1; BT.2020 = 9; PQ transfer = 16, HLG = 18; BT.2020-NCL matrix = 9), `full_range` is
|
||||
/// 0 (limited) or 1 (full), `bit_depth` is 8 or 10. A `transfer` of 16/18 means HDR — configure an
|
||||
/// HDR present path and drain [`punktfunk_connection_next_hdr_meta`]. Available immediately after a
|
||||
/// successful connect (these don't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; each out pointer is NULL or writable for its scalar.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
c: *mut PunktfunkConnection,
|
||||
primaries: *mut u8,
|
||||
transfer: *mut u8,
|
||||
matrix: *mut u8,
|
||||
full_range: *mut u8,
|
||||
bit_depth: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
let color = c.inner.color;
|
||||
unsafe {
|
||||
if !primaries.is_null() {
|
||||
*primaries = color.primaries;
|
||||
}
|
||||
if !transfer.is_null() {
|
||||
*transfer = color.transfer;
|
||||
}
|
||||
if !matrix.is_null() {
|
||||
*matrix = color.matrix;
|
||||
}
|
||||
if !full_range.is_null() {
|
||||
*full_range = color.full_range;
|
||||
}
|
||||
if !bit_depth.is_null() {
|
||||
*bit_depth = c.inner.bit_depth;
|
||||
}
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@@ -16,8 +16,8 @@ use crate::error::{PunktfunkError, Result};
|
||||
use crate::input::InputEvent;
|
||||
use crate::packet::FLAG_PROBE;
|
||||
use crate::quic::{
|
||||
endpoint, io, window_loss_ppm, Hello, HidOutput, LossReport, ProbeRequest, ProbeResult,
|
||||
Reconfigure, Reconfigured, RequestKeyframe, RichInput, Start, Welcome,
|
||||
endpoint, io, window_loss_ppm, ColorInfo, HdrMeta, Hello, HidOutput, LossReport, ProbeRequest,
|
||||
ProbeResult, Reconfigure, Reconfigured, RequestKeyframe, RichInput, Start, Welcome,
|
||||
};
|
||||
use crate::session::{Frame, Session};
|
||||
use crate::transport::UdpTransport;
|
||||
@@ -40,7 +40,18 @@ enum CtrlRequest {
|
||||
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's
|
||||
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
|
||||
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
|
||||
type Negotiated = (Mode, CompositorPref, GamepadPref, [u8; 32], u32, i64);
|
||||
/// The trailing `u8` is the resolved encode bit depth (8/10) and [`ColorInfo`] the resolved colour
|
||||
/// signalling, both from the [`Welcome`].
|
||||
type Negotiated = (
|
||||
Mode,
|
||||
CompositorPref,
|
||||
GamepadPref,
|
||||
[u8; 32],
|
||||
u32,
|
||||
i64,
|
||||
u8,
|
||||
ColorInfo,
|
||||
);
|
||||
|
||||
/// 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
|
||||
@@ -121,6 +132,10 @@ const RUMBLE_QUEUE: usize = 16;
|
||||
/// Same overflow discipline as rumble; the host re-sends on the next feedback change.
|
||||
const HIDOUT_QUEUE: usize = 32;
|
||||
|
||||
/// Static HDR metadata (ST.2086 mastering + content light level) buffered for the embedder. Tiny
|
||||
/// and low-rate (one on start, re-sent on mastering changes / keyframes); a small ring is ample.
|
||||
const HDR_META_QUEUE: usize = 8;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
@@ -140,6 +155,8 @@ pub struct NativeClient {
|
||||
rumble: Mutex<Receiver<(u16, u16, u16)>>,
|
||||
/// Inbound DualSense feedback (lightbar / player LEDs / adaptive triggers) — 0xCD datagrams.
|
||||
hidout: Mutex<Receiver<HidOutput>>,
|
||||
/// Inbound static HDR metadata (ST.2086 mastering + content light level) — 0xCE datagrams.
|
||||
hdr_meta: Mutex<Receiver<HdrMeta>>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
/// Outbound mic frames `(seq, pts_ns, opus)` → encoded as 0xCB datagrams by the worker.
|
||||
mic_tx: tokio::sync::mpsc::UnboundedSender<(u32, u64, Vec<u8>)>,
|
||||
@@ -178,6 +195,13 @@ pub struct NativeClient {
|
||||
/// glass-to-glass latency valid across machines. `0` = no correction (an old host that didn't
|
||||
/// answer, or genuinely synced clocks).
|
||||
pub clock_offset_ns: i64,
|
||||
/// 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.
|
||||
pub bit_depth: u8,
|
||||
/// The colour signalling the host encodes with ([`Welcome::color`]): the client configures its
|
||||
/// decoder/presenter from this. [`ColorInfo::SDR_BT709`] for an older host. The static HDR
|
||||
/// mastering metadata (when [`ColorInfo::is_hdr`]) arrives via [`NativeClient::next_hdr_meta`].
|
||||
pub color: ColorInfo,
|
||||
}
|
||||
|
||||
/// Pin the calling thread to the user-interactive QoS class on Apple targets.
|
||||
@@ -231,6 +255,7 @@ impl NativeClient {
|
||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||
let (hidout_tx, hidout_rx) = std::sync::mpsc::sync_channel::<HidOutput>(HIDOUT_QUEUE);
|
||||
let (hdr_meta_tx, hdr_meta_rx) = std::sync::mpsc::sync_channel::<HdrMeta>(HDR_META_QUEUE);
|
||||
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 (rich_input_tx, rich_input_rx) = tokio::sync::mpsc::unbounded_channel::<RichInput>();
|
||||
@@ -280,6 +305,7 @@ impl NativeClient {
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
input_rx,
|
||||
mic_rx,
|
||||
rich_input_rx,
|
||||
@@ -301,6 +327,8 @@ impl NativeClient {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
@@ -315,6 +343,7 @@ impl NativeClient {
|
||||
audio: Mutex::new(audio_rx),
|
||||
rumble: Mutex::new(rumble_rx),
|
||||
hidout: Mutex::new(hidout_rx),
|
||||
hdr_meta: Mutex::new(hdr_meta_rx),
|
||||
input_tx,
|
||||
mic_tx,
|
||||
rich_input_tx,
|
||||
@@ -329,6 +358,8 @@ impl NativeClient {
|
||||
resolved_gamepad,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -579,6 +610,20 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next static HDR metadata update (ST.2086 mastering display + content light level)
|
||||
/// the host sent for an HDR session; same timeout/closed semantics as
|
||||
/// [`NativeClient::next_hidout`]. The host sends one near session start and re-sends it on
|
||||
/// mastering changes / keyframes, so an HDR presenter should drain this on its own thread and
|
||||
/// apply the latest value to the display (DXGI `SetHDRMetaData` / `CAEDRMetadata` /
|
||||
/// `KEY_HDR_STATIC_INFO`). Only an HDR session (`color.is_hdr()`, PQ) ever emits these.
|
||||
pub fn next_hdr_meta(&self, timeout: Duration) -> Result<HdrMeta> {
|
||||
match self.hdr_meta.lock().unwrap().recv_timeout(timeout) {
|
||||
Ok(m) => Ok(m),
|
||||
Err(RecvTimeoutError::Timeout) => Err(PunktfunkError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(PunktfunkError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one input event for delivery as a QUIC datagram.
|
||||
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
||||
self.input_tx.send(*ev).map_err(|_| PunktfunkError::Closed)
|
||||
@@ -628,6 +673,7 @@ struct WorkerArgs {
|
||||
audio_tx: SyncSender<AudioPacket>,
|
||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||
hidout_tx: SyncSender<HidOutput>,
|
||||
hdr_meta_tx: SyncSender<HdrMeta>,
|
||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
mic_rx: tokio::sync::mpsc::UnboundedReceiver<(u32, u64, Vec<u8>)>,
|
||||
rich_input_rx: tokio::sync::mpsc::UnboundedReceiver<RichInput>,
|
||||
@@ -658,6 +704,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
hidout_tx,
|
||||
hdr_meta_tx,
|
||||
mut input_rx,
|
||||
mut mic_rx,
|
||||
mut rich_input_rx,
|
||||
@@ -785,6 +832,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
welcome.bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
welcome.bit_depth,
|
||||
welcome.color,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -799,6 +848,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -813,6 +864,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
fingerprint,
|
||||
resolved_bitrate_kbps,
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
@@ -927,6 +980,11 @@ async fn worker_main(args: WorkerArgs) {
|
||||
let _ = hidout_tx.try_send(h);
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::HDR_META_MAGIC) => {
|
||||
if let Some(m) = crate::quic::decode_hdr_meta_datagram(&d) {
|
||||
let _ = hdr_meta_tx.try_send(m);
|
||||
}
|
||||
}
|
||||
_ => {} // unknown tag — a newer host; ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,72 @@ pub const VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
pub const VIDEO_CAP_HDR: u8 = 0x02;
|
||||
|
||||
/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the
|
||||
/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these
|
||||
/// instead of inferring them from the bitstream VUI. An older host omits the bytes on the wire →
|
||||
/// [`ColorInfo::SDR_BT709`] (the 8-bit BT.709 limited stream every pre-HDR build produced).
|
||||
///
|
||||
/// The *static* HDR mastering metadata (ST.2086 + content light level) is larger and can change
|
||||
/// mid-stream, so it rides the [`HDR_META_MAGIC`] datagram rather than this fixed struct.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ColorInfo {
|
||||
/// CICP colour primaries: 1 = BT.709, 9 = BT.2020.
|
||||
pub primaries: u8,
|
||||
/// CICP transfer characteristics: 1 = BT.709, 16 = PQ (SMPTE ST.2084), 18 = HLG.
|
||||
pub transfer: u8,
|
||||
/// CICP matrix coefficients: 1 = BT.709, 9 = BT.2020 non-constant-luminance.
|
||||
pub matrix: u8,
|
||||
/// `video_full_range_flag`: 0 = limited/studio range, 1 = full range.
|
||||
pub full_range: u8,
|
||||
}
|
||||
|
||||
impl ColorInfo {
|
||||
/// CICP colour-primaries code point: BT.709.
|
||||
pub const CP_BT709: u8 = 1;
|
||||
/// CICP colour-primaries code point: BT.2020.
|
||||
pub const CP_BT2020: u8 = 9;
|
||||
/// CICP transfer code point: BT.709.
|
||||
pub const TRC_BT709: u8 = 1;
|
||||
/// CICP transfer code point: PQ (SMPTE ST.2084).
|
||||
pub const TRC_PQ: u8 = 16;
|
||||
/// CICP transfer code point: HLG (ARIB STD-B67 / BT.2100).
|
||||
pub const TRC_HLG: u8 = 18;
|
||||
/// CICP matrix code point: BT.709.
|
||||
pub const MC_BT709: u8 = 1;
|
||||
/// CICP matrix code point: BT.2020 non-constant-luminance. (Never emit 10 / constant-luminance —
|
||||
/// no client decodes it.)
|
||||
pub const MC_BT2020_NCL: u8 = 9;
|
||||
|
||||
/// 8-bit BT.709 limited-range SDR — what every pre-HDR build produced, and the back-compat
|
||||
/// default when a peer omits the colour bytes.
|
||||
pub const SDR_BT709: ColorInfo = ColorInfo {
|
||||
primaries: Self::CP_BT709,
|
||||
transfer: Self::TRC_BT709,
|
||||
matrix: Self::MC_BT709,
|
||||
full_range: 0,
|
||||
};
|
||||
|
||||
/// BT.2020 PQ (HDR10), limited range — what the Windows host's HEVC VUI emits.
|
||||
pub const HDR10_BT2020_PQ: ColorInfo = ColorInfo {
|
||||
primaries: Self::CP_BT2020,
|
||||
transfer: Self::TRC_PQ,
|
||||
matrix: Self::MC_BT2020_NCL,
|
||||
full_range: 0,
|
||||
};
|
||||
|
||||
/// True when the transfer is an HDR curve (PQ or HLG): the stream needs HDR present, and
|
||||
/// (for PQ) a [`HdrMeta`] datagram carries the mastering metadata.
|
||||
pub fn is_hdr(&self) -> bool {
|
||||
self.transfer == Self::TRC_PQ || self.transfer == Self::TRC_HLG
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ColorInfo {
|
||||
fn default() -> Self {
|
||||
Self::SDR_BT709
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest device name carried in a [`Hello`] (bytes of UTF-8; longer names are truncated on
|
||||
/// encode, rejected on decode — a one-byte length prefix caps it at 255 anyway).
|
||||
pub const HELLO_NAME_MAX: usize = 64;
|
||||
@@ -124,9 +190,14 @@ pub struct Welcome {
|
||||
/// The luma/chroma bit depth the host actually encodes at — `8` (default / older host) or
|
||||
/// `10` (Main10, enabled only when the client advertised [`VIDEO_CAP_10BIT`]). The client
|
||||
/// configures its decoder for 10-bit (P010) when this is `10`. Appended to the wire form as a
|
||||
/// single trailing byte; `8` when an older host omitted it. (Color space stays BT.709 in
|
||||
/// Phase 1; BT.2020 PQ HDR signaling is added alongside HDR support.)
|
||||
/// single trailing byte; `8` when an older host omitted it.
|
||||
pub bit_depth: u8,
|
||||
/// The colour signalling (CICP primaries/transfer/matrix/range) the host encodes with — BT.709
|
||||
/// limited SDR by default, BT.2020 PQ when a 10-bit HDR session was negotiated. Appended after
|
||||
/// `bit_depth` as 4 trailing bytes; an older host that omits them decodes to
|
||||
/// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of
|
||||
/// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`].
|
||||
pub color: ColorInfo,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -671,6 +742,11 @@ impl Welcome {
|
||||
b.push(self.gamepad.to_u8()); // appended at offset 54 — same back-compat discipline
|
||||
b.extend_from_slice(&self.bitrate_kbps.to_le_bytes()); // appended at offset 55..59
|
||||
b.push(self.bit_depth); // appended at offset 59 — older clients read [0..59] and skip it
|
||||
// Colour signalling at offsets 60..64 — older clients stop before these → SDR BT.709.
|
||||
b.push(self.color.primaries);
|
||||
b.push(self.color.transfer);
|
||||
b.push(self.color.matrix);
|
||||
b.push(self.color.full_range);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -678,7 +754,8 @@ impl Welcome {
|
||||
// Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22]
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] (compositor/gamepad/bitrate/bit_depth are optional trailing bytes).
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// (everything from compositor on is an optional trailing byte; an older host stops earlier).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -728,6 +805,13 @@ impl Welcome {
|
||||
// Optional trailing byte — absent on an older host → `8` (8-bit, the only depth they
|
||||
// encode).
|
||||
bit_depth: b.get(59).copied().unwrap_or(8),
|
||||
// Optional trailing colour bytes — absent on an older host → SDR BT.709 limited.
|
||||
color: ColorInfo {
|
||||
primaries: b.get(60).copied().unwrap_or(ColorInfo::CP_BT709),
|
||||
transfer: b.get(61).copied().unwrap_or(ColorInfo::TRC_BT709),
|
||||
matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709),
|
||||
full_range: b.get(63).copied().unwrap_or(0),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -988,7 +1072,8 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
/// 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).
|
||||
/// 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
|
||||
@@ -1203,6 +1288,79 @@ impl HidOutput {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
})
|
||||
}
|
||||
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io {
|
||||
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||
@@ -1636,10 +1794,34 @@ mod tests {
|
||||
gamepad: GamepadPref::DualSense,
|
||||
bitrate_kbps: 50_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
|
||||
#[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 hello_start_roundtrip() {
|
||||
let h = Hello {
|
||||
@@ -1760,9 +1942,10 @@ mod tests {
|
||||
gamepad: GamepadPref::Xbox360,
|
||||
bitrate_kbps: 120_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 60);
|
||||
assert_eq!(wenc.len(), 64); // 60 base + 4 colour bytes
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
@@ -1778,8 +1961,17 @@ mod tests {
|
||||
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!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user