From 001e66a949da86bd881e0492282571c7acf3c22f Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:49:33 +0800 Subject: [PATCH 01/11] fix: ignore unparseable control messages instead of ending session --- moonshine-core/src/session/stream/control/mod.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/moonshine-core/src/session/stream/control/mod.rs b/moonshine-core/src/session/stream/control/mod.rs index 415e806e..b2e59d7f 100644 --- a/moonshine-core/src/session/stream/control/mod.rs +++ b/moonshine-core/src/session/stream/control/mod.rs @@ -86,7 +86,10 @@ impl TryFrom for ControlMessageType { x if x == Self::SetMotionEvent as u16 => Ok(Self::SetMotionEvent), x if x == Self::SetRgbLed as u16 => Ok(Self::SetRgbLed), x if x == Self::SetTriggerEffect as u16 => Ok(Self::SetTriggerEffect), - _ => Err(()), + _ => { + tracing::debug!("Ignoring unknown control message type: {v:#06x}"); + Err(()) + }, } } } @@ -510,7 +513,9 @@ async fn run_control_loop( Ok(Some(Event::Receive { ref packet, .. })) => { let mut control_message = match ControlMessage::from_bytes(packet.data()) { Ok(control_message) => control_message, - Err(()) => break, + // Ignore messages we can't parse (e.g. types introduced by newer + // clients) instead of tearing down the whole session. + Err(()) => continue, }; tracing::trace!("Received control message: {control_message:?}"); From ceac86d24d4b66f280e1501ade4c0aeb5ccdd92e Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:50:11 +0800 Subject: [PATCH 02/11] fix: drive gamepad touchpad from touch event type, not pressure --- .../session/stream/control/input/gamepad.rs | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/moonshine-core/src/session/stream/control/input/gamepad.rs b/moonshine-core/src/session/stream/control/input/gamepad.rs index 460db41e..7f47dcd8 100644 --- a/moonshine-core/src/session/stream/control/input/gamepad.rs +++ b/moonshine-core/src/session/stream/control/input/gamepad.rs @@ -144,15 +144,22 @@ impl GamepadInfo { } } +// Moonlight touch lifecycle event types (LI_TOUCH_EVENT_*). +const TOUCH_EVENT_DOWN: u8 = 0x01; +const TOUCH_EVENT_UP: u8 = 0x02; +const TOUCH_EVENT_MOVE: u8 = 0x03; +const TOUCH_EVENT_CANCEL: u8 = 0x04; +const TOUCH_EVENT_CANCEL_ALL: u8 = 0x07; + #[derive(Debug)] pub(crate) struct GamepadTouch { pub index: u8, - _event_type: u8, + event_type: u8, // zero: [u8; 2], // Alignment/reserved pointer_id: u32, pub x: f32, pub y: f32, - pub pressure: f32, + _pressure: f32, } impl GamepadTouch { @@ -177,12 +184,12 @@ impl GamepadTouch { Ok(Self { index: buffer[0], - _event_type: buffer[1], + event_type: buffer[1], // zero: u16::from_le_bytes(buffer[2..4].try_into().unwrap()), pointer_id: u32::from_le_bytes(buffer[4..8].try_into().unwrap()), x: f32::from_le_bytes(buffer[8..12].try_into().unwrap()).clamp(0.0, 1.0), y: f32::from_le_bytes(buffer[12..16].try_into().unwrap()).clamp(0.0, 1.0), - pressure: f32::from_le_bytes(buffer[16..20].try_into().unwrap()).clamp(0.0, 1.0), + _pressure: f32::from_le_bytes(buffer[16..20].try_into().unwrap()).clamp(0.0, 1.0), }) } } @@ -344,6 +351,9 @@ pub(crate) struct Gamepad { /// The underlying inputtino joypad, used to inject button presses, stick /// positions, triggers, touchpad events, and motion data. gamepad: inputtino::Joypad, + + /// Active touchpad pointer ids, tracked so CancelAll can release them. + touch_points: Vec, } impl Gamepad { @@ -461,7 +471,10 @@ impl Gamepad { } }); - Ok(Self { gamepad }) + Ok(Self { + gamepad, + touch_points: Vec::new(), + }) } /// Apply button flags to the gamepad. @@ -482,14 +495,30 @@ impl Gamepad { pub fn touch(&mut self, touch: &GamepadTouch) { if let Joypad::PS5(gamepad) = &self.gamepad { - if touch.pressure > 0.5 { - gamepad.place_finger( - touch.pointer_id, - (touch.x * PS5Joypad::TOUCHPAD_WIDTH as f32) as u16, - (touch.y * PS5Joypad::TOUCHPAD_HEIGHT as f32) as u16, - ); - } else { - gamepad.release_finger(touch.pointer_id); + // Drive the touchpad from Moonlight's explicit touch lifecycle event + // rather than inferring up/down from pressure, which clients don't + // reliably populate (the DualSense touchpad has no pressure sensor). + match touch.event_type { + TOUCH_EVENT_DOWN | TOUCH_EVENT_MOVE => { + gamepad.place_finger( + touch.pointer_id, + (touch.x * PS5Joypad::TOUCHPAD_WIDTH as f32) as u16, + (touch.y * PS5Joypad::TOUCHPAD_HEIGHT as f32) as u16, + ); + if !self.touch_points.contains(&touch.pointer_id) { + self.touch_points.push(touch.pointer_id); + } + }, + TOUCH_EVENT_UP | TOUCH_EVENT_CANCEL => { + gamepad.release_finger(touch.pointer_id); + self.touch_points.retain(|id| *id != touch.pointer_id); + }, + TOUCH_EVENT_CANCEL_ALL => { + for pointer_id in self.touch_points.drain(..) { + gamepad.release_finger(pointer_id); + } + }, + _ => {}, } } } From 47f910bba00cd7f01f6cf8f67fc312369ede69bd Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:50:32 +0800 Subject: [PATCH 03/11] fix: drain feedback queue fully and enlarge input channels --- .../src/session/stream/control/input/mod.rs | 4 +++- .../src/session/stream/control/mod.rs | 23 +++++++++++-------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/moonshine-core/src/session/stream/control/input/mod.rs b/moonshine-core/src/session/stream/control/input/mod.rs index c2e4fb28..c0859507 100644 --- a/moonshine-core/src/session/stream/control/input/mod.rs +++ b/moonshine-core/src/session/stream/control/input/mod.rs @@ -127,7 +127,9 @@ impl InputHandler { stop_session_manager: ShutdownManager, gamepad_config: GamepadConfig, ) -> Result { - let (gamepad_tx, gamepad_rx) = mpsc::channel(10); + // Sized generously: the control loop awaits sends into this channel, so a + // full channel stalls all input processing and ENet servicing. + let (gamepad_tx, gamepad_rx) = mpsc::channel(64); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() diff --git a/moonshine-core/src/session/stream/control/mod.rs b/moonshine-core/src/session/stream/control/mod.rs index b2e59d7f..a9d8b3ff 100644 --- a/moonshine-core/src/session/stream/control/mod.rs +++ b/moonshine-core/src/session/stream/control/mod.rs @@ -467,7 +467,9 @@ async fn run_control_loop( let mut stop_deadline = std::time::Instant::now() + std::time::Duration::from_secs(stream_timeout); // Create a channel over which we can receive feedback messages to send to the connected client. - let (feedback_tx, mut feedback_rx) = mpsc::channel::(10); + // Sized generously: the inputtino IO thread can produce feedback (rumble, LED, + // trigger effects) in bursts and blocks if this channel fills up. + let (feedback_tx, mut feedback_rx) = mpsc::channel::(64); // Sequence number of feedback messages. let mut sequence_number = 0u32; @@ -486,15 +488,16 @@ async fn run_control_loop( break; } - // Check for feedback messages. - if let Ok(command) = feedback_rx.try_recv() - && let Some(peer_id) = connected_peer - { - tracing::debug!("Sending control feedback command: {command:?}"); - let payload = command.as_packet(); - let key = context.keys_rx.borrow().remote_input_key.clone(); - send_to_peer(&mut host, peer_id, &key, sequence_number, &payload, "feedback"); - sequence_number += 1; + // Drain all pending feedback messages so a burst doesn't back up the + // channel (and stall the inputtino IO thread that produces them). + while let Ok(command) = feedback_rx.try_recv() { + if let Some(peer_id) = connected_peer { + tracing::debug!("Sending control feedback command: {command:?}"); + let payload = command.as_packet(); + let key = context.keys_rx.borrow().remote_input_key.clone(); + send_to_peer(&mut host, peer_id, &key, sequence_number, &payload, "feedback"); + sequence_number += 1; + } } match host From 95c54049ac786d133cbd4c1549bf96396b6ce684 Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:44:13 +0800 Subject: [PATCH 04/11] perf: cache control-stream AES-GCM cipher across packets --- moonshine-core/src/crypto.rs | 79 +++++++----- .../src/session/stream/control/mod.rs | 121 ++++++++++++------ 2 files changed, 131 insertions(+), 69 deletions(-) diff --git a/moonshine-core/src/crypto.rs b/moonshine-core/src/crypto.rs index 1d9ba882..4da9483e 100644 --- a/moonshine-core/src/crypto.rs +++ b/moonshine-core/src/crypto.rs @@ -3,44 +3,63 @@ use aes::cipher::BlockModeEncrypt; use aes::cipher::KeyIvInit; use aes_gcm::{ Aes128Gcm, Key, Nonce, - aead::{Aead, KeyInit}, + aead::{AeadInPlace, KeyInit, generic_array::GenericArray}, }; use inout::block_padding::Pkcs7; -pub(crate) fn encrypt(plaintext: &[u8], key: &[u8], iv: &[u8], tag: &mut [u8]) -> Result, aes_gcm::Error> { - let key = Key::::from_slice(key); - let nonce = Nonce::from_slice(iv); - let cipher = Aes128Gcm::new(key); - - // In OpenSSL, encrypting with GCM returns ciphertext usually without tag appended if you use `tag()` to retrieve it separate. - // aes-gcm crate append tag to ciphertext. - let mut ciphertext = cipher.encrypt(nonce, plaintext)?; +/// An AES-128-GCM cipher cached across calls, keyed by the control stream's +/// input key. +/// +/// The control stream encrypts feedback and decrypts input using +/// `remote_input_key`, which only changes on key rotation (tracked by +/// `remote_input_key_id`). Rebuilding the cipher — a full AES key schedule plus +/// GHASH table — on every packet showed up on the per-input-event path, so we +/// cache it and only rebuild when the key id changes. +pub(crate) struct GcmCipher { + cipher: Option, + key_id: i64, +} - // Split tag from ciphertext - let tag_len = 16; - let len = ciphertext.len(); - if len < tag_len { - return Err(aes_gcm::Error); +impl GcmCipher { + pub fn new() -> Self { + Self { + cipher: None, + key_id: i64::MIN, + } } - let actual_ciphertext_len = len - tag_len; - - tag.copy_from_slice(&ciphertext[actual_ciphertext_len..]); - ciphertext.truncate(actual_ciphertext_len); - - Ok(ciphertext) -} -pub(crate) fn decrypt(ciphertext: &[u8], key: &[u8], iv: &[u8], tag: &[u8]) -> Result, aes_gcm::Error> { - let key = Key::::from_slice(key); - let nonce = Nonce::from_slice(iv); - let cipher = Aes128Gcm::new(key); + /// Return the cached cipher, rebuilding it if the key has rotated. + fn get(&mut self, key: &[u8], key_id: i64) -> Result<&Aes128Gcm, ()> { + if self.cipher.is_none() || self.key_id != key_id { + if key.len() != 16 { + tracing::warn!("Control key must be 16 bytes, got {}.", key.len()); + self.cipher = None; + return Err(()); + } + self.cipher = Some(Aes128Gcm::new(Key::::from_slice(key))); + self.key_id = key_id; + } + self.cipher.as_ref().ok_or(()) + } - // Append tag to ciphertext for aes-gcm crate - let mut payload = Vec::with_capacity(ciphertext.len() + tag.len()); - payload.extend_from_slice(ciphertext); - payload.extend_from_slice(tag); + /// Encrypt `buffer` in place, returning the detached 16-byte tag. + pub fn encrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], buffer: &mut [u8]) -> Result<[u8; 16], ()> { + let cipher = self.get(key, key_id)?; + let tag = cipher + .encrypt_in_place_detached(Nonce::from_slice(iv), b"", buffer) + .map_err(|e| tracing::warn!("Failed to encrypt control data: {e}"))?; + let mut out = [0u8; 16]; + out.copy_from_slice(&tag); + Ok(out) + } - cipher.decrypt(nonce, payload.as_ref()) + /// Decrypt `buffer` in place using the detached `tag`. + pub fn decrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], tag: &[u8], buffer: &mut [u8]) -> Result<(), ()> { + let cipher = self.get(key, key_id)?; + cipher + .decrypt_in_place_detached(Nonce::from_slice(iv), b"", buffer, GenericArray::from_slice(tag)) + .map_err(|e| tracing::warn!("Failed to decrypt control message: {e}")) + } } pub(crate) fn encrypt_cbc(data: &[u8], key: &[u8], iv: &[u8]) -> Result, String> { diff --git a/moonshine-core/src/session/stream/control/mod.rs b/moonshine-core/src/session/stream/control/mod.rs index a9d8b3ff..36a84221 100644 --- a/moonshine-core/src/session/stream/control/mod.rs +++ b/moonshine-core/src/session/stream/control/mod.rs @@ -9,7 +9,7 @@ use tokio_enet::{Event, Host, HostConfig, Packet, PacketMode, PeerState}; use self::input::gamepad::GamepadConfig; use self::{feedback::FeedbackCommand, input::InputHandler}; -use crate::crypto::{decrypt, encrypt}; +use crate::crypto::GcmCipher; use crate::session::SessionContext; use crate::session::SessionKeysReceiver; use crate::session::compositor::{ @@ -227,33 +227,29 @@ impl EncryptedControlMessage { } } -fn encode_control(key: &[u8], sequence_number: u32, payload: &[u8]) -> Result, ()> { +fn encode_control( + cipher: &mut GcmCipher, + key: &[u8], + key_id: i64, + sequence_number: u32, + payload: &[u8], +) -> Result, ()> { let mut initialization_vector = [0u8; 12]; initialization_vector[0..4].copy_from_slice(&sequence_number.to_le_bytes()); initialization_vector[10] = b'H'; initialization_vector[11] = b'C'; - if key.len() != 16 { - tracing::warn!("Key length has {} bytes, but expected {} bytes.", key.len(), 16); - return Err(()); - } - - let mut tag = [0u8; 16]; - let payload = encrypt(payload, key, &initialization_vector, &mut tag) - .map_err(|e| tracing::warn!("Failed to encrypt control data: {e}"))?; - - if payload.is_empty() { - tracing::warn!("Failed to encrypt control data."); - return Err(()); - } + // Encrypt in place; AES-GCM ciphertext is the same length as the plaintext. + let mut buffer = payload.to_vec(); + let tag = cipher.encrypt(key, key_id, &initialization_vector, &mut buffer)?; let message = EncryptedControlMessage { length: std::mem::size_of::() as u16 // Sequence number. + ENCRYPTION_TAG_LENGTH as u16 // Tag. - + payload.len() as u16, // Payload. + + buffer.len() as u16, // Payload. sequence_number, tag, - payload, + payload: buffer, }; Ok(message.as_bytes()) @@ -411,15 +407,18 @@ fn build_termination_payload(error_code: u32) -> Vec { } /// Send an encrypted control packet to the connected peer if it exists and is connected. +#[allow(clippy::too_many_arguments)] fn send_to_peer( host: &mut Host, + cipher: &mut GcmCipher, peer_id: tokio_enet::PeerId, key: &[u8], + key_id: i64, sequence_number: u32, payload: &[u8], label: &str, ) { - if let Ok(packet) = encode_control(key, sequence_number, payload) + if let Ok(packet) = encode_control(cipher, key, key_id, sequence_number, payload) && let Some(peer) = host.peer_mut(peer_id) && peer.state() == PeerState::Connected { @@ -430,11 +429,14 @@ fn send_to_peer( } /// Build and send an HDR mode control message, then advance `sequence_number`. +#[allow(clippy::too_many_arguments)] fn send_hdr_state( host: &mut Host, + cipher: &mut GcmCipher, peer_id: tokio_enet::PeerId, state: &HdrModeState, key: &[u8], + key_id: i64, sequence_number: &mut u32, label: &str, ) { @@ -444,7 +446,7 @@ fn send_hdr_state( None }; let payload = build_hdr_mode_payload(state.enabled, metadata.as_ref()); - send_to_peer(host, peer_id, key, *sequence_number, &payload, label); + send_to_peer(host, cipher, peer_id, key, key_id, *sequence_number, &payload, label); *sequence_number += 1; tracing::debug!("Sent HDR mode ({label}) to client: enabled={}", state.enabled); } @@ -478,6 +480,10 @@ async fn run_control_loop( // Track which peer slot the client is connected to. let mut connected_peer: Option = None; + // Cached AES-GCM cipher, shared by the input (decrypt) and feedback (encrypt) + // directions, rebuilt only when the input key rotates. + let mut cipher = GcmCipher::new(); + while !stop_session_manager.is_shutdown_triggered() { // Check if the timeout has passed. if std::time::Instant::now() > stop_deadline { @@ -494,8 +500,20 @@ async fn run_control_loop( if let Some(peer_id) = connected_peer { tracing::debug!("Sending control feedback command: {command:?}"); let payload = command.as_packet(); - let key = context.keys_rx.borrow().remote_input_key.clone(); - send_to_peer(&mut host, peer_id, &key, sequence_number, &payload, "feedback"); + let (key, key_id) = { + let keys = context.keys_rx.borrow(); + (keys.remote_input_key.clone(), keys.remote_input_key_id) + }; + send_to_peer( + &mut host, + &mut cipher, + peer_id, + &key, + key_id, + sequence_number, + &payload, + "feedback", + ); sequence_number += 1; } } @@ -530,21 +548,24 @@ async fn run_control_loop( initialization_vector[10] = b'C'; initialization_vector[11] = b'C'; - let keys = &*context.keys_rx.borrow(); - let decrypted_result = decrypt( - &message.payload, - &keys.remote_input_key, - &initialization_vector, - &message.tag, - ); - - decrypted = match decrypted_result { - Ok(decrypted) => decrypted, - Err(e) => { - tracing::warn!("Failed to decrypt control message: {:?}", e); + // Decrypt the ciphertext in place using the cached cipher. + let mut buffer = message.payload; + { + let keys = context.keys_rx.borrow(); + if cipher + .decrypt( + &keys.remote_input_key, + keys.remote_input_key_id, + &initialization_vector, + &message.tag, + &mut buffer, + ) + .is_err() + { continue; - }, - }; + } + } + decrypted = buffer; control_message = match ControlMessage::from_bytes(&decrypted) { Ok(decrypted_message) => decrypted_message, @@ -595,9 +616,21 @@ async fn run_control_loop( send_hdr_mode = false; if let Some(peer_id) = connected_peer { let state = hdr_metadata_rx.borrow_and_update().clone(); - let key = context.keys_rx.borrow().remote_input_key.clone(); + let (key, key_id) = { + let keys = context.keys_rx.borrow(); + (keys.remote_input_key.clone(), keys.remote_input_key_id) + }; tracing::info!("Informing client: HDR session"); - send_hdr_state(&mut host, peer_id, &state, &key, &mut sequence_number, "initial"); + send_hdr_state( + &mut host, + &mut cipher, + peer_id, + &state, + &key, + key_id, + &mut sequence_number, + "initial", + ); } } @@ -607,12 +640,17 @@ async fn run_control_loop( && let Some(peer_id) = connected_peer { let state = hdr_metadata_rx.borrow_and_update().clone(); - let key = context.keys_rx.borrow().remote_input_key.clone(); + let (key, key_id) = { + let keys = context.keys_rx.borrow(); + (keys.remote_input_key.clone(), keys.remote_input_key_id) + }; send_hdr_state( &mut host, + &mut cipher, peer_id, &state, &key, + key_id, &mut sequence_number, "metadata update", ); @@ -626,11 +664,16 @@ async fn run_control_loop( // client as a graceful shutdown so it does not display an error. let termination_payload = build_termination_payload(0x80030023); if let Some(peer_id) = connected_peer { - let key = context.keys_rx.borrow().remote_input_key.clone(); + let (key, key_id) = { + let keys = context.keys_rx.borrow(); + (keys.remote_input_key.clone(), keys.remote_input_key_id) + }; send_to_peer( &mut host, + &mut cipher, peer_id, &key, + key_id, sequence_number, &termination_payload, "termination", From e738698b88a515c4aaee38adf67970a3023f63cd Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:45:35 +0800 Subject: [PATCH 05/11] perf: drop always-on timing instrumentation from packetizer --- .../src/session/stream/video/packetizer.rs | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/moonshine-core/src/session/stream/video/packetizer.rs b/moonshine-core/src/session/stream/video/packetizer.rs index 6b1aad10..dcb698c0 100644 --- a/moonshine-core/src/session/stream/video/packetizer.rs +++ b/moonshine-core/src/session/stream/video/packetizer.rs @@ -4,7 +4,6 @@ use aes_gcm::{ }; use fec_rs::ReedSolomon; use std::collections::{HashMap, hash_map::Entry}; -use std::time::Instant; use crate::session::SessionKeysReceiver; @@ -250,13 +249,6 @@ impl Packetizer { // Accumulate all blocks into a single batch. let mut all_shards = ShardBatch::empty(); - let mut total_alloc_us = 0u128; - let mut total_data_write_us = 0u128; - let mut total_fec_encoder_us = 0u128; - let mut total_fec_compute_us = 0u128; - let mut total_fec_headers_us = 0u128; - let mut total_extend_us = 0u128; - for block_index in 0..nr_blocks { let start = block_index * nr_data_shards_per_block; let mut end = ((block_index + 1) * nr_data_shards_per_block).min(nr_data_shards); @@ -275,13 +267,11 @@ impl Packetizer { .max(minimum_fec_packets as usize) .min(MAX_SHARDS.saturating_sub(nr_data_shards)); - let t_fec_encoder = Instant::now(); let encoder = if nr_parity_shards > 0 { Some(self.get_fec_encoder(nr_data_shards, nr_parity_shards)?) } else { None }; - total_fec_encoder_us += t_fec_encoder.elapsed().as_micros(); // Recompute the actual FEC percentage in case of a rounding error or when there are 0 parity shards. let fec_percentage = nr_parity_shards * 100 / nr_data_shards; @@ -292,11 +282,7 @@ impl Packetizer { // Single allocation for all shards in this block (data + parity), zeroed. let total_shards = nr_data_shards + nr_parity_shards; - let t_alloc = Instant::now(); let mut shard_buf = ShardBuf::new(total_shards, requested_shard_size, prefix_size); - total_alloc_us += t_alloc.elapsed().as_micros(); - - let t_data_write = Instant::now(); // Write data shards directly into the flat buffer. for (block_shard_index, data_shard_index) in (start..end).enumerate() { @@ -343,22 +329,14 @@ impl Packetizer { // Parity shards are already zeroed from ShardBuf::new(). - total_data_write_us += t_data_write.elapsed().as_micros(); - if let Some(encoder) = encoder { // Create FEC-compatible slice views into the flat buffer. let mut fec_slices = shard_buf.as_fec_slices(); - let t_fec_compute = Instant::now(); - encoder .encode(&mut fec_slices) .map_err(|e| tracing::warn!("Failed to encode packet as FEC shards: {e}"))?; - total_fec_compute_us += t_fec_compute.elapsed().as_micros(); - - let t_fec_headers = Instant::now(); - // Write headers for parity shards. FEC overwrites the entire shard // content, so we patch the fields Moonlight needs afterward. for block_shard_index in 0..nr_parity_shards { @@ -380,8 +358,6 @@ impl Packetizer { *sequence_number += 1; } - - total_fec_headers_us += t_fec_headers.elapsed().as_micros(); } // Encrypt each shard if video encryption is enabled. @@ -409,9 +385,7 @@ impl Packetizer { } } - let t_extend = Instant::now(); all_shards.extend_from(&shard_buf.into_batch()); - total_extend_us += t_extend.elapsed().as_micros(); tracing::trace!("Finished sending frame {frame_number}."); @@ -420,10 +394,6 @@ impl Packetizer { } } - tracing::trace!( - "Packetize breakdown: alloc_us={total_alloc_us} data_write_us={total_data_write_us} fec_encoder_us={total_fec_encoder_us} fec_compute_us={total_fec_compute_us} fec_headers_us={total_fec_headers_us} extend_us={total_extend_us}", - ); - Ok(all_shards) } From 354abcc3d115313a6e66cef86f7ab808f9b574c0 Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:46:10 +0800 Subject: [PATCH 06/11] perf: move single block into shard batch instead of copying --- moonshine-core/src/session/stream/video/packetizer.rs | 2 +- .../src/session/stream/video/shard_batch.rs | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/moonshine-core/src/session/stream/video/packetizer.rs b/moonshine-core/src/session/stream/video/packetizer.rs index dcb698c0..393cd57e 100644 --- a/moonshine-core/src/session/stream/video/packetizer.rs +++ b/moonshine-core/src/session/stream/video/packetizer.rs @@ -385,7 +385,7 @@ impl Packetizer { } } - all_shards.extend_from(&shard_buf.into_batch()); + all_shards.extend(shard_buf.into_batch()); tracing::trace!("Finished sending frame {frame_number}."); diff --git a/moonshine-core/src/session/stream/video/shard_batch.rs b/moonshine-core/src/session/stream/video/shard_batch.rs index 1f637448..9c26a393 100644 --- a/moonshine-core/src/session/stream/video/shard_batch.rs +++ b/moonshine-core/src/session/stream/video/shard_batch.rs @@ -37,12 +37,15 @@ impl ShardBatch { /// Append all shards from `other` into this batch. /// /// Both batches must have the same shard_size (or `self` must be empty). - pub fn extend_from(&mut self, other: &ShardBatch) { - debug_assert!(self.shard_size == 0 || self.shard_size == other.shard_size); + /// When `self` is still empty — the common single-block case — `other`'s + /// buffer is moved in directly rather than copied. + pub fn extend(&mut self, mut other: ShardBatch) { if self.shard_size == 0 { - self.shard_size = other.shard_size; + *self = other; + return; } - self.data.extend_from_slice(&other.data); + debug_assert!(self.shard_size == other.shard_size); + self.data.append(&mut other.data); } } From faebfd5583bf1822720fa4aa1e30810c8a0106b3 Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:35:05 +0800 Subject: [PATCH 07/11] fix: isolate virtual inputs from host seat --- dist/60-moonshine.rules | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dist/60-moonshine.rules b/dist/60-moonshine.rules index d5f23840..e11a762f 100644 --- a/dist/60-moonshine.rules +++ b/dist/60-moonshine.rules @@ -1,4 +1,4 @@ KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess", GROUP="input", MODE="0660" KERNEL=="uhid", TAG+="uaccess", GROUP="input", MODE="0660" -SUBSYSTEM=="hidraw", KERNELS=="uhid", TAG+="uaccess", GROUP="input", MODE="0660" -SUBSYSTEMS=="input", ATTRS{name}=="Moonshine *", TAG+="uaccess", GROUP="input", MODE="0660" +SUBSYSTEM=="hidraw", KERNELS=="uhid", ATTRS{name}=="Moonshine *", ENV{ID_SEAT}="seat9", MODE="0666" +SUBSYSTEMS=="input", ATTRS{name}=="Moonshine *", ENV{ID_SEAT}="seat9", MODE="0666" From ddb16424d0588059fdbc94cb5badad3b6fd21295 Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Mon, 15 Jun 2026 00:52:49 +0800 Subject: [PATCH 08/11] fix: tighten control feedback sends --- moonshine-core/src/crypto.rs | 4 +- .../session/stream/control/input/gamepad.rs | 1 + .../src/session/stream/control/mod.rs | 57 +++++++++++-------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/moonshine-core/src/crypto.rs b/moonshine-core/src/crypto.rs index 4da9483e..200b2b22 100644 --- a/moonshine-core/src/crypto.rs +++ b/moonshine-core/src/crypto.rs @@ -53,8 +53,8 @@ impl GcmCipher { Ok(out) } - /// Decrypt `buffer` in place using the detached `tag`. - pub fn decrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], tag: &[u8], buffer: &mut [u8]) -> Result<(), ()> { + /// Decrypt `buffer` in place using the detached 16-byte tag. + pub fn decrypt(&mut self, key: &[u8], key_id: i64, iv: &[u8], tag: &[u8; 16], buffer: &mut [u8]) -> Result<(), ()> { let cipher = self.get(key, key_id)?; cipher .decrypt_in_place_detached(Nonce::from_slice(iv), b"", buffer, GenericArray::from_slice(tag)) diff --git a/moonshine-core/src/session/stream/control/input/gamepad.rs b/moonshine-core/src/session/stream/control/input/gamepad.rs index 7f47dcd8..27c57812 100644 --- a/moonshine-core/src/session/stream/control/input/gamepad.rs +++ b/moonshine-core/src/session/stream/control/input/gamepad.rs @@ -353,6 +353,7 @@ pub(crate) struct Gamepad { gamepad: inputtino::Joypad, /// Active touchpad pointer ids, tracked so CancelAll can release them. + /// DualSense reports at most two concurrent touch points, so a Vec stays tiny. touch_points: Vec, } diff --git a/moonshine-core/src/session/stream/control/mod.rs b/moonshine-core/src/session/stream/control/mod.rs index 36a84221..f2bfe2c2 100644 --- a/moonshine-core/src/session/stream/control/mod.rs +++ b/moonshine-core/src/session/stream/control/mod.rs @@ -5,7 +5,7 @@ use async_shutdown::ShutdownManager; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; use tokio::sync::watch; -use tokio_enet::{Event, Host, HostConfig, Packet, PacketMode, PeerState}; +use tokio_enet::{Event, Host, HostConfig, Packet, PacketMode, PeerId, PeerState}; use self::input::gamepad::GamepadConfig; use self::{feedback::FeedbackCommand, input::InputHandler}; @@ -406,34 +406,43 @@ fn build_termination_payload(error_code: u32) -> Vec { buf } -/// Send an encrypted control packet to the connected peer if it exists and is connected. +/// Send an encrypted control packet to the connected peer. +/// +/// Returns whether the packet was actually handed to ENet, so callers only +/// advance `sequence_number` for messages the client will really see. #[allow(clippy::too_many_arguments)] fn send_to_peer( host: &mut Host, + peer_id: PeerId, cipher: &mut GcmCipher, - peer_id: tokio_enet::PeerId, key: &[u8], key_id: i64, sequence_number: u32, payload: &[u8], label: &str, -) { - if let Ok(packet) = encode_control(cipher, key, key_id, sequence_number, payload) - && let Some(peer) = host.peer_mut(peer_id) - && peer.state() == PeerState::Connected - { - let _ = peer - .send(0, Packet::new(packet.as_slice(), PacketMode::ReliableSequenced)) - .map_err(|e| tracing::warn!("Failed to send {label} to peer: {e}")); +) -> bool { + let Some(peer) = host.peer_mut(peer_id) else { + return false; + }; + if peer.state() != PeerState::Connected { + return false; } + + let Ok(packet) = encode_control(cipher, key, key_id, sequence_number, payload) else { + return false; + }; + + peer.send(0, Packet::new(packet.as_slice(), PacketMode::ReliableSequenced)) + .map_err(|e| tracing::warn!("Failed to send {label} to peer: {e}")) + .is_ok() } /// Build and send an HDR mode control message, then advance `sequence_number`. #[allow(clippy::too_many_arguments)] fn send_hdr_state( host: &mut Host, + peer_id: PeerId, cipher: &mut GcmCipher, - peer_id: tokio_enet::PeerId, state: &HdrModeState, key: &[u8], key_id: i64, @@ -446,9 +455,10 @@ fn send_hdr_state( None }; let payload = build_hdr_mode_payload(state.enabled, metadata.as_ref()); - send_to_peer(host, cipher, peer_id, key, key_id, *sequence_number, &payload, label); - *sequence_number += 1; - tracing::debug!("Sent HDR mode ({label}) to client: enabled={}", state.enabled); + if send_to_peer(host, peer_id, cipher, key, key_id, *sequence_number, &payload, label) { + *sequence_number += 1; + tracing::debug!("Sent HDR mode ({label}) to client: enabled={}", state.enabled); + } } #[allow(clippy::too_many_arguments)] @@ -478,7 +488,7 @@ async fn run_control_loop( let mut send_hdr_mode = false; let mut audio_triggered = false; // Track which peer slot the client is connected to. - let mut connected_peer: Option = None; + let mut connected_peer: Option = None; // Cached AES-GCM cipher, shared by the input (decrypt) and feedback (encrypt) // directions, rebuilt only when the input key rotates. @@ -504,17 +514,18 @@ async fn run_control_loop( let keys = context.keys_rx.borrow(); (keys.remote_input_key.clone(), keys.remote_input_key_id) }; - send_to_peer( + if send_to_peer( &mut host, - &mut cipher, peer_id, + &mut cipher, &key, key_id, sequence_number, &payload, "feedback", - ); - sequence_number += 1; + ) { + sequence_number += 1; + } } } @@ -623,8 +634,8 @@ async fn run_control_loop( tracing::info!("Informing client: HDR session"); send_hdr_state( &mut host, - &mut cipher, peer_id, + &mut cipher, &state, &key, key_id, @@ -646,8 +657,8 @@ async fn run_control_loop( }; send_hdr_state( &mut host, - &mut cipher, peer_id, + &mut cipher, &state, &key, key_id, @@ -670,8 +681,8 @@ async fn run_control_loop( }; send_to_peer( &mut host, - &mut cipher, peer_id, + &mut cipher, &key, key_id, sequence_number, From de15e27e85e3e9c3d567cdb0a6b108bc25ede2ab Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:01:17 +0800 Subject: [PATCH 09/11] fix: defer resume IDR until reconnecting client address is re-learned --- moonshine-core/src/session/manager.rs | 12 ++--- moonshine-core/src/session/mod.rs | 4 +- .../src/session/stream/video/mod.rs | 46 +++++++++++++++---- 3 files changed, 47 insertions(+), 15 deletions(-) diff --git a/moonshine-core/src/session/manager.rs b/moonshine-core/src/session/manager.rs index 90381933..2ef67a21 100644 --- a/moonshine-core/src/session/manager.rs +++ b/moonshine-core/src/session/manager.rs @@ -371,14 +371,14 @@ impl SessionManager { // Resume (reconnect): the streams are already running, so PLAY is a // no-op — the client picks up the existing streams once it PINGs. // The reconnecting client is a fresh Moonlight session that expects - // frame numbers to start at 1, so reset the video frame counters and - // force an IDR; otherwise it sees the running counter as a huge frame - // gap and reports a poor connection. + // frame numbers to start at 1, so arm a video stream reset (frame-counter + // reset + forced IDR); otherwise it sees the running counter as a huge + // frame gap and reports a poor connection. The reset fires once the packet + // handler re-learns the client's (usually new) address from its first PING, + // so the forced IDR isn't sent to the stale previous address. active.reset_video_stream(); guard.session = Some(SessionState::Active(active)); - tracing::info!( - "Resuming active session: resetting video frame counter and treating PLAY as no-op." - ); + tracing::info!("Resuming active session: arming video stream reset and treating PLAY as no-op."); return Ok(()); }, None => { diff --git a/moonshine-core/src/session/mod.rs b/moonshine-core/src/session/mod.rs index 66586b69..a1711ab6 100644 --- a/moonshine-core/src/session/mod.rs +++ b/moonshine-core/src/session/mod.rs @@ -339,7 +339,9 @@ impl ActiveSession { &self.context } - /// Reset the video stream's frame counters and force an IDR for a resuming client. + /// Arm a video stream reset (frame-counter reset + forced IDR) for a resuming client. + /// The reset fires once the packet handler re-learns the client's address from its + /// first PING; see [`VideoStreamHandle::request_reset`]. pub(crate) fn reset_video_stream(&self) { self.video_handle.request_reset(); } diff --git a/moonshine-core/src/session/stream/video/mod.rs b/moonshine-core/src/session/stream/video/mod.rs index b3c553a6..3858b7d6 100644 --- a/moonshine-core/src/session/stream/video/mod.rs +++ b/moonshine-core/src/session/stream/video/mod.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use async_shutdown::ShutdownManager; use serde::{Deserialize, Serialize}; @@ -189,7 +190,9 @@ pub(crate) struct VideoStreamHandle { /// Reference frame invalidation requests, carrying the inclusive /// `[first, last]` client frame-index range the client could not decode. invalidate_tx: broadcast::Sender<(u32, u32)>, - reset_tx: broadcast::Sender<()>, + /// Set on resume to arm a stream reset; the packet handler fires it once it has + /// re-learned the reconnecting client's address (see `request_reset`). + resume_pending: Arc, } impl VideoStreamHandle { @@ -218,15 +221,23 @@ impl VideoStreamHandle { let _ = self.invalidate_tx.send((first, last)); } - /// Reset the stream's frame/sequence counters for a resuming client. + /// Arm a stream reset for a resuming client. /// /// Called when a client reconnects to an already-running session. The pipeline /// keeps incrementing `frame_number` for the lifetime of the session, but a fresh /// Moonlight session expects frame numbers to start at 1; without a reset it counts - /// the jump as massive frame loss and reports a poor connection. This also forces an - /// IDR so the resumed client has a decodable starting frame. + /// the jump as massive frame loss and reports a poor connection. The reset also forces + /// an IDR so the resumed client has a decodable starting frame. + /// + /// The reset is not fired immediately: the packet handler still holds the previous + /// connection's address, and a reconnecting client almost always arrives on a new UDP + /// source port. Firing now would spend the forced IDR on the stale address, the client + /// would receive no decodable frame, and it would abort with a connection error + /// (typically recovering only on a retry). Instead we arm a flag that the packet handler + /// consumes once it has re-learned the client's address from its first PING, so the IDR + /// lands where the client is actually listening. pub fn request_reset(&self) { - let _ = self.reset_tx.send(()); + self.resume_pending.store(true, Ordering::Relaxed); } /// Clone the start notify for external triggering (e.g. bench binary). @@ -300,14 +311,23 @@ impl VideoStream { // of loss reports; the encode loop drains all pending each iteration. let (invalidate_tx, _invalidate_rx) = broadcast::channel(16); - // Stream-reset broadcast channel (client reconnect/resume). + // Stream-reset broadcast channel (client reconnect/resume). The packet handler + // fires it once it has re-learned the reconnecting client's address. let (reset_tx, _reset_rx) = broadcast::channel(1); + let resume_pending = Arc::new(AtomicBool::new(false)); // Packet channel. let (packet_tx, packet_rx) = mpsc::channel::(128); // Spawn packet handler — gated behind start_notify. - spawn_handle_video_packets(packet_rx, socket, start_notify.clone(), stop.clone()); + spawn_handle_video_packets( + packet_rx, + socket, + start_notify.clone(), + reset_tx.clone(), + resume_pending.clone(), + stop.clone(), + ); // Spawn pipeline thread — gated behind start_notify. VideoPipeline::new( @@ -330,7 +350,7 @@ impl VideoStream { notify: start_notify, idr_tx, invalidate_tx, - reset_tx, + resume_pending, }) } } @@ -339,6 +359,8 @@ fn spawn_handle_video_packets( mut packet_rx: mpsc::Receiver, socket: UdpGsoSocket, start: Arc, + reset_tx: broadcast::Sender<()>, + resume_pending: Arc, stop_session_manager: ShutdownManager, ) { tokio::spawn(async move { @@ -405,6 +427,14 @@ fn spawn_handle_video_packets( if &buf[..len] == b"PING" { tracing::trace!("Received video stream PING message from {address}."); client_address = Some(address); + + // A resume armed a stream reset (frame-counter reset + forced IDR). Fire it + // now that we know where the reconnecting client is listening, so the forced + // IDR is sent to the current address instead of the previous connection's. + if resume_pending.swap(false, Ordering::Relaxed) { + tracing::info!("Re-learned client address after resume; firing armed stream reset."); + let _ = reset_tx.send(()); + } } else { tracing::warn!("Received unknown message on video stream of length {len}."); } From 851a36bb34caf706debfe089f963348029dbd8e4 Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:56:40 +0800 Subject: [PATCH 10/11] fix: raise the focused window so Big Picture cannot cover the game --- .../src/session/compositor/handlers.rs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/moonshine-core/src/session/compositor/handlers.rs b/moonshine-core/src/session/compositor/handlers.rs index 236c2adb..24057d08 100644 --- a/moonshine-core/src/session/compositor/handlers.rs +++ b/moonshine-core/src/session/compositor/handlers.rs @@ -994,6 +994,34 @@ impl MoonshineCompositor { self.clear_dropdowns(); } + // Raise the focused window to the top of the stack. + // + // Stacking order is otherwise fixed at map time and never revisited, + // so focus and z-order can disagree indefinitely: a window mapped + // earlier keeps covering the focused one. Launching a game from Steam + // Big Picture hits this — Big Picture stays mapped and on top, and + // blanks its own surface while the game starts, so the composited + // output is black even though the game renders correctly underneath. + // + // `activate: false` reorders the stack without touching activation + // state, preserving the `_NET_WM_STATE_FOCUSED` handling that Wine + // depends on for input delivery (see `mapped_override_redirect_window`). + self.space.raise_element(best, false); + + // Overlays and dropdowns belong above the focused window — gamescope + // layers base < overlay < external overlay. Re-raise them so the line + // above cannot bury the Steam overlay behind the game. + for layer in [ + self.overlay_window.clone(), + self.external_overlay_window.clone(), + self.override_window.clone(), + ] + .into_iter() + .flatten() + { + self.space.raise_element(&layer, false); + } + // Find the overlay window with input_focus_mode != 0. // Gamescope: looks for overlayWindow with inputFocusMode set. let overlay_with_input_focus: Option = self.overlay_window.as_ref().and_then(|w| { From bd5e30351b5f301291e3a086fe9483546745004b Mon Sep 17 00:00:00 2001 From: urwrstkn8mare <42769125+urwrstkn8mare@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:39:24 +0800 Subject: [PATCH 11/11] fix: fall back to 4:2:0 when the encoder lacks the requested chroma sampling --- .../src/session/stream/video/pipeline/mod.rs | 114 +++++++++++++----- 1 file changed, 85 insertions(+), 29 deletions(-) diff --git a/moonshine-core/src/session/stream/video/pipeline/mod.rs b/moonshine-core/src/session/stream/video/pipeline/mod.rs index 66175941..705e66f6 100644 --- a/moonshine-core/src/session/stream/video/pipeline/mod.rs +++ b/moonshine-core/src/session/stream/video/pipeline/mod.rs @@ -468,7 +468,7 @@ struct VideoPipelineInner { impl VideoPipelineInner { #[allow(clippy::too_many_arguments)] fn run( - self, + mut self, runtime: tokio::runtime::Handle, frame_rx: std::sync::mpsc::Receiver, packet_tx: mpsc::Sender, @@ -501,7 +501,7 @@ impl VideoPipelineInner { } // Create the encoder. - let (context, encoder) = match self.create_encoder() { + let (context, encoder, chroma_sampling) = match self.create_encoder() { Ok(result) => result, Err(e) => { tracing::error!("Failed to create video encoder: {e}"); @@ -509,6 +509,11 @@ impl VideoPipelineInner { }, }; + // Adopt the sampling the encoder was actually created with, so the color + // converter below (and the packet consumer's copy of the context) agree + // with the encoder's input format after a fallback. + self.context.chroma_sampling_type = chroma_sampling; + // Start the capture and encoding loop. if let Err(e) = self.run_encoding_loop( runtime, @@ -529,7 +534,14 @@ impl VideoPipelineInner { tracing::debug!("Video pipeline stopped."); } - fn create_encoder(&self) -> Result<(VideoContext, Encoder), String> { + /// Create the video encoder, returning it alongside the chroma sampling it + /// was actually created with. + /// + /// The returned sampling may differ from the client's request — see the + /// fallback below — and callers must use it in place of + /// `context.chroma_sampling_type` so the color converter's output format + /// stays in sync with the encoder's input format. + fn create_encoder(&self) -> Result<(VideoContext, Encoder, VideoChromaSampling), String> { let ctx = &self.context; // Create Vulkan video context. @@ -544,12 +556,6 @@ impl VideoPipelineInner { VideoFormat::Av1 => Codec::AV1, }; - // Convert pixel format. - let pixel_format = match ctx.chroma_sampling_type { - VideoChromaSampling::Yuv420 => PixelFormat::Yuv420, - VideoChromaSampling::Yuv444 => PixelFormat::Yuv444, - }; - // Convert bit depth based on dynamic range. let bit_depth = match ctx.dynamic_range { VideoDynamicRange::Sdr => pixelforge::EncodeBitDepth::Eight, @@ -562,27 +568,77 @@ impl VideoPipelineInner { VideoDynamicRange::Hdr => ColorDescription::bt2020_pq().with_full_range(ctx.full_range), }; - // Create encode configuration. - let config = match codec { - Codec::H264 => EncodeConfig::h264(ctx.width, ctx.height), - Codec::H265 => EncodeConfig::h265(ctx.width, ctx.height), - Codec::AV1 => EncodeConfig::av1(ctx.width, ctx.height), + // Chroma sampling comes straight from the client's SDP, but not every + // encoder implements every profile: AMD's VCN exposes 4:2:0 profiles + // only, so a client with "YUV 4:4:4" enabled would fail session setup + // with a bare ERROR_VIDEO_PROFILE_CODEC_NOT_SUPPORTED_KHR. 4:2:0 is + // supported by every Vulkan Video encoder, so try the requested sampling + // first and fall back to it rather than dropping the session. The client + // reads the actual sampling from the bitstream's sequence header, so a + // downgrade needs no renegotiation. + let mut samplings = vec![ctx.chroma_sampling_type]; + if ctx.chroma_sampling_type != VideoChromaSampling::Yuv420 { + samplings.push(VideoChromaSampling::Yuv420); } - .with_pixel_format(pixel_format) - .with_bit_depth(bit_depth) - .with_color_description(color_description) - .with_rate_control(RateControlMode::Cbr) - .with_target_bitrate(ctx.bitrate as u32) - .with_frame_rate(ctx.fps, 1) - .with_gop_size(0) // Infinite GOP, we'll request IDR frames manually - .with_b_frames(0) // No B-frames for low latency - .with_max_reference_frames(ctx.max_reference_frames) - .with_virtual_buffer_size_ms(1000 / ctx.fps) - .with_initial_virtual_buffer_size_ms(0); - - let encoder = Encoder::new(context.clone(), config).map_err(|e| format!("Failed to create encoder: {e}"))?; - - Ok((context, encoder)) + + let mut last_error = None; + for chroma_sampling in samplings { + let pixel_format = match chroma_sampling { + VideoChromaSampling::Yuv420 => PixelFormat::Yuv420, + VideoChromaSampling::Yuv444 => PixelFormat::Yuv444, + }; + + // Create encode configuration. + let config = match codec { + Codec::H264 => EncodeConfig::h264(ctx.width, ctx.height), + Codec::H265 => EncodeConfig::h265(ctx.width, ctx.height), + Codec::AV1 => EncodeConfig::av1(ctx.width, ctx.height), + } + .with_pixel_format(pixel_format) + .with_bit_depth(bit_depth) + .with_color_description(color_description) + .with_rate_control(RateControlMode::Cbr) + .with_target_bitrate(ctx.bitrate as u32) + .with_frame_rate(ctx.fps, 1) + .with_gop_size(0) // Infinite GOP, we'll request IDR frames manually + .with_b_frames(0) // No B-frames for low latency + .with_max_reference_frames(ctx.max_reference_frames) + .with_virtual_buffer_size_ms(1000 / ctx.fps) + .with_initial_virtual_buffer_size_ms(0); + + match Encoder::new(context.clone(), config) { + Ok(encoder) => { + if chroma_sampling != ctx.chroma_sampling_type { + tracing::warn!( + "This GPU cannot encode {:?} at {:?}, falling back to {:?}. \ + Disable 4:4:4 in the client to silence this.", + codec, + ctx.chroma_sampling_type, + chroma_sampling, + ); + } + return Ok((context, encoder, chroma_sampling)); + }, + Err(e) => last_error = Some(e), + } + } + + // Every candidate failed, so the unsupported part of the profile is + // something we cannot substitute (most often 10-bit for an HDR session + // on a codec that has no 10-bit profile, such as H.264). Spell out the + // full profile — the underlying Vulkan error names none of it. + let detail = last_error.map(|e| format!(" Encoder error: {e}")).unwrap_or_default(); + Err(format!( + "Failed to create encoder for {codec:?} {:?} {bit_depth:?} ({}): \ + this GPU does not support that combination. \ + HDR requires a codec with a 10-bit profile (HEVC or AV1).{detail}", + ctx.chroma_sampling_type, + if ctx.dynamic_range == VideoDynamicRange::Hdr { + "HDR" + } else { + "SDR" + }, + )) } #[allow(clippy::too_many_arguments)]