From 0e7f6d85d684cd64f1c4432140ead3efbaa282f3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 21:35:16 +0200 Subject: [PATCH] feat(gamestream): signal HDR mode to the client via the 0x010e control message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit punktfunk negotiates HDR correctly (the stream is BT.2020 PQ 10-bit) but never told the client to switch its display into HDR picture mode, so HDR streams stayed in SDR on the TV. Sunshine sends an async control-stream message (IDX_HDR_MODE, type 0x010e, carrying SS_HDR_METADATA) shortly after the stream starts; Moonlight only flips the TV into HDR on that cue. We sent exactly one host→client control message (rumble 0x010B) and never this one. (aurora-tv PR #53 worked around it client-side by inferring HDR from the negotiated format; this fixes it host-side so stock Moonlight clients switch too.) Once the control stream is live and the session negotiated HDR, send a one-shot 0x010e message (enable + 26-byte SS_HDR_METADATA from generic HDR10 defaults), latched per connection and re-armed on Disconnect. The rumble sequence counter is unified into a single monotonic `host_seq` shared by both message types — the GCM nonce derives from `seq`, so a separate per-type counter would reuse (key, nonce) pairs in the host direction. The BT.2020 PQ VUI already rides the bitstream (the encoder derives it from the P010 capture format), so this is purely the missing "switch now" signal; no encoder change needed. Wire layout locked by unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../punktfunk-host/src/gamestream/control.rs | 127 +++++++++++++++++- 1 file changed, 120 insertions(+), 7 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index cebf64ea..d233b158 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -26,6 +26,7 @@ use super::{AppState, CONTROL_PORT}; use crate::inject::gamepad::GamepadManager; use anyhow::{anyhow, Context, Result}; use punktfunk_core::input::InputEvent; +use punktfunk_core::quic::HdrMeta; use rusty_enet::{Event, Host, HostSettings, Packet, PeerID}; use std::net::UdpSocket; use std::sync::mpsc::Sender; @@ -65,9 +66,14 @@ pub fn spawn(state: Arc) -> Result<()> { // state and lives on its own thread (see crate::inject::InjectorService); the held // `inj_tx` clone keeps it alive for the control thread's lifetime. let inj_tx = crate::inject::InjectorService::start().sender(); - // Virtual gamepads (uinput) + the host→client rumble sequence counter. + // Virtual gamepads (uinput). ONE monotonic host→client control sequence counter, shared + // by every outbound message (rumble + the HDR-mode signal): the GCM nonce is derived + // from `seq`, so a per-message-type counter would reuse (key, nonce) pairs across + // message types in the host direction. let mut pads = GamepadManager::new(); - let mut rumble_seq: u32 = 0; + let mut host_seq: u32 = 0; + // One-shot latch for the HDR-mode control message (0x010e); re-armed on Disconnect. + let mut hdr_sent = false; let mut peer: Option = None; loop { loop { @@ -82,6 +88,8 @@ pub fn spawn(state: Arc) -> Result<()> { detected = None; decrypt_fails = 0; peer = None; + // Re-arm the HDR-mode signal for the next connection. + hdr_sent = false; // Unplug the session's virtual pads. pads = GamepadManager::new(); } @@ -112,8 +120,9 @@ pub fn spawn(state: Arc) -> Result<()> { // SECURITY NOTE (audit #5, legacy GCM nonce reuse): on the LEGACY control scheme // (`NonceKind::Legacy*`, which we hit because we advertise no encryption) the nonce is // just the per-direction `seq` (`iv[0]=seq&0xff`, rest zero) with NO direction byte — - // so host rumble (this `rumble_seq`) and client input (its own seq) share the same - // (key, nonce) space when their seqs collide. This is INHERENT to Nvidia's old-style + // so host control messages (this `host_seq`, shared by rumble + the HDR-mode signal) + // and client input (its own seq) share the same (key, nonce) space when their seqs + // collide. This is INHERENT to Nvidia's old-style // GameStream control encryption (Apollo/moonlight-common-c are identical: only the V2 // scheme adds `iv[10..12] = 'H','C'` to separate the host direction). It can't be fixed // on the legacy wire without breaking Moonlight; the GCM key is the client-supplied @@ -124,15 +133,39 @@ pub fn spawn(state: Arc) -> Result<()> { let key = state.launch.lock().unwrap().map(|s| s.gcm_key); if let Some(key) = key { let mut out: Vec> = Vec::new(); + // One-shot HDR-mode signal (type 0x010e / Sunshine `IDX_HDR_MODE`) once the + // control stream is live. Stock Moonlight clients only flip the TV into HDR + // picture mode when they receive this async message — the video is already + // BT.2020 PQ, but without the cue the display stays in SDR mode (the exact + // symptom aurora-tv PR #53 worked around client-side). Sent before rumble so + // the client sees HDR as early as possible. + if !hdr_sent { + // `state.stream` is populated by the RTSP ANNOUNCE, which precedes the + // control stream — but guard the race: only commit the one-shot decision + // once a config exists, so a not-yet-set stream can't latch us into never + // signaling HDR. A non-HDR session latches too (it never needs the msg). + if let Some(hdr) = state.stream.lock().unwrap().map(|s| s.hdr) { + if hdr { + let pt = + hdr_mode_plaintext(true, &pf_frame::hdr::generic_hdr10()); + out.push(encrypt_control(&key, &scheme, host_seq, &pt)); + host_seq = host_seq.wrapping_add(1); + tracing::info!( + "control: signaled HDR mode ON to client (0x010e)" + ); + } + hdr_sent = true; + } + } pads.pump_rumble(|index, low, high| { let pt = super::gamepad::rumble_plaintext(index, low, high); - out.push(encrypt_control(&key, &scheme, rumble_seq, &pt)); - rumble_seq = rumble_seq.wrapping_add(1); + out.push(encrypt_control(&key, &scheme, host_seq, &pt)); + host_seq = host_seq.wrapping_add(1); }); for wire in out { if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..])) { - tracing::warn!(error = ?e, "rumble send failed"); + tracing::warn!(error = ?e, "control send failed"); } } } @@ -394,6 +427,56 @@ fn decrypt_control( None } +/// Serialize an [`HdrMeta`] into Moonlight's `SS_HDR_METADATA` control-message layout: 26 bytes, +/// all **little-endian** (moonlight-common-c parses it with `BYTE_ORDER_LITTLE`), primaries in +/// **R, G, B** order — note [`HdrMeta`]/ST.2086 stores them G, B, R, so we reorder. Luminance is +/// re-scaled to the wire units the client reads: `maxDisplayLuminance`/`maxFullFrameLuminance` in +/// whole nits, `minDisplayLuminance` in 1/10000-nit, content light levels already in nits. There is +/// no separate full-frame luminance in our metadata, so it mirrors the mastering peak. +fn ss_hdr_metadata(m: &HdrMeta) -> [u8; 26] { + let max_display_nits = (m.max_display_mastering_luminance / 10_000).min(u16::MAX as u32) as u16; + let min_display = m.min_display_mastering_luminance.min(u16::MAX as u32) as u16; + let mut b = [0u8; 26]; + let mut o = 0; + let mut put = |v: u16| { + b[o..o + 2].copy_from_slice(&v.to_le_bytes()); + o += 2; + }; + // displayPrimaries[3] in R, G, B order (HdrMeta is G, B, R). + for p in [ + m.display_primaries[2], + m.display_primaries[0], + m.display_primaries[1], + ] { + put(p[0]); + put(p[1]); + } + put(m.white_point[0]); + put(m.white_point[1]); + put(max_display_nits); // maxDisplayLuminance (nits) + put(min_display); // minDisplayLuminance (1/10000 nit) + put(m.max_cll); // maxContentLightLevel (nits) + put(m.max_fall); // maxFrameAverageLightLevel (nits) + put(max_display_nits); // maxFullFrameLuminance (nits) — no separate value; mirror the peak + debug_assert_eq!(o, 26); + b +} + +/// Build the host→client HDR-mode control plaintext (type `0x010e` / Sunshine `IDX_HDR_MODE`): +/// `[u16 type][u16 length][u8 enabled][SS_HDR_METADATA]`, all little-endian, `length` counting the +/// enable byte + metadata (mirrors [`super::gamepad::rumble_plaintext`]). Moonlight flips the +/// TV/decoder into HDR picture mode on `enabled != 0` (`ConnListenerSetHdrMode`). We advertise a +/// Sunshine server, so the client (`IS_SUNSHINE()`) reads the full 26-byte metadata block. +fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec { + let meta = ss_hdr_metadata(m); + let mut pt = Vec::with_capacity(4 + 1 + meta.len()); + pt.extend_from_slice(&0x010eu16.to_le_bytes()); // type + pt.extend_from_slice(&((1 + meta.len()) as u16).to_le_bytes()); // length = enable + metadata + pt.push(enabled as u8); + pt.extend_from_slice(&meta); + pt +} + /// Seal a host→client control message, mirroring the client's `detected` scheme with the /// direction flipped: V2 nonces use marker `H?` (host-originated) instead of `C?`; legacy /// nonces keep their construction with our own independent `seq` counter. Wire layout matches @@ -494,4 +577,34 @@ mod tests { assert_eq!(decode_rfi_range(&rfi_msg(-1, 9)), None); // negative first assert_eq!(decode_rfi_range(&rfi_msg(9, 4)), None); // last < first } + + /// The HDR-mode plaintext must match what moonlight-common-c parses: `[u16 type=0x010e] + /// [u16 length=27][u8 enable][SS_HDR_METADATA]`, 31 bytes, all little-endian, primaries R,G,B. + #[test] + fn hdr_mode_plaintext_wire_layout() { + let pt = super::hdr_mode_plaintext(true, &pf_frame::hdr::generic_hdr10()); + assert_eq!(pt.len(), 31); // 4 header + 1 enable + 26 metadata + assert_eq!(&pt[0..2], &0x010eu16.to_le_bytes()); // type + assert_eq!(&pt[2..4], &27u16.to_le_bytes()); // length = enable + metadata + assert_eq!(pt[4], 1); // enabled + // Metadata starts at byte 5, R primary first (HdrMeta stores G,B,R; wire is R,G,B). + assert_eq!(&pt[5..7], &35400u16.to_le_bytes()); // red.x + assert_eq!(&pt[7..9], &14600u16.to_le_bytes()); // red.y + assert_eq!(&pt[9..11], &8500u16.to_le_bytes()); // green.x + assert_eq!(&pt[13..15], &6550u16.to_le_bytes()); // blue.x + assert_eq!(&pt[17..19], &15635u16.to_le_bytes()); // whitePoint.x + assert_eq!(&pt[21..23], &1000u16.to_le_bytes()); // maxDisplayLuminance (nits) + assert_eq!(&pt[23..25], &1u16.to_le_bytes()); // minDisplayLuminance (1/10000 nit) + assert_eq!(&pt[25..27], &1000u16.to_le_bytes()); // maxContentLightLevel (MaxCLL) + assert_eq!(&pt[27..29], &400u16.to_le_bytes()); // maxFrameAverageLightLevel (MaxFALL) + assert_eq!(&pt[29..31], &1000u16.to_le_bytes()); // maxFullFrameLuminance mirrors the peak + } + + #[test] + fn hdr_mode_plaintext_disabled_still_well_formed() { + let pt = super::hdr_mode_plaintext(false, &pf_frame::hdr::generic_hdr10()); + assert_eq!(pt.len(), 31); + assert_eq!(&pt[2..4], &27u16.to_le_bytes()); + assert_eq!(pt[4], 0); // disabled + } }