From 02b94b828d47f45ffa44c8a8cb5eeaed4fe3028d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 20:04:54 +0200 Subject: [PATCH] fix(gamestream): the termination type comes from the version we advertise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Still -1 on glass. The message was going out; the client was ignoring it. Which packet-type table a client reads is decided by exactly one thing — moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)` — and we advertise 7.1.431, so every client reads the ENCRYPTED table, where termination is 0x0109. We were sending 0x0100. Deriving that from the nonce scheme was the error: `NonceKind` describes how the GCM nonce is built and nothing else. The two look like the same "is this encrypted" axis and are not. The HDR message could never have caught it either, since 0x010e is identical in both tables — so the only symptom was the silence this whole chain has been about. Pinned with a test asserting APP_VERSION still clears 7.1.431, because if it ever drops below, the correct type silently becomes 0x0100 again and the stream goes back to ending as an error with nothing failing to explain why. .133: 281 passed (+3), same 4 environmental native::tests failures. The Linux box is off the network, so the full Linux suite is owed. --- .../punktfunk-host/src/gamestream/control.rs | 70 ++++++++++--------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index ec568d8c..a2206fa1 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -188,7 +188,7 @@ pub fn spawn(state: Arc) -> Result<()> { // Only sealable once the client's scheme is known; without it, fall through // to the bare disconnect rather than send a packet it can't read. if let (Some(scheme), Some(key)) = (detected, last_key) { - let pt = termination_plaintext(&scheme); + let pt = termination_plaintext(); let wire = encrypt_control(&key, &scheme, host_seq, &pt); host_seq = host_seq.wrapping_add(1); if let Err(e) = host.peer_mut(pid).send(0, &Packet::reliable(&wire[..])) @@ -614,25 +614,28 @@ fn hdr_mode_plaintext(enabled: bool, m: &HdrMeta) -> Vec { /// /// Verified against moonlight-common-c `ControlStream.c` (master, 2026-07-26): /// -/// * **Type** is table-dependent, and the client picks the table from what we negotiated: -/// `packetTypesGen7[IDX_TERMINATION] = 0x0100`, `packetTypesGen7Enc[…] = 0x0109`. We advertise no -/// encryption support, so a legacy-nonce client is on the plain table; a V2 client is on the -/// encrypted one. Sending the wrong one is merely ignored, but then we are back to a bare -/// disconnect — so derive it rather than guess. +/// * **Type `0x0109`**, from `packetTypesGen7Enc[IDX_TERMINATION]`. Which table the client reads is +/// decided by ONE thing — the version we advertise: +/// `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and +/// [`super::APP_VERSION`] is exactly `7.1.431`. So every client is on the encrypted table, where +/// termination is `0x0109`; the plain table's `0x0100` would be ignored. +/// +/// This is *not* the same axis as [`NonceKind`], which only describes how the GCM nonce is built. +/// Deriving the type from the nonce scheme looks reasonable and is wrong — it sent `0x0100` to a +/// client reading the encrypted table, which ignored it silently and reported the session as `-1` +/// (on glass, .173). The HDR message could never have caught this: `0x010e` in both tables. /// * **Payload** is a big-endian `u32` reason (the ≥6-byte "extended" branch; the short branch is a /// little-endian `u16`, which is GFE's older shape). /// * **`0x80030023`** is `NVST_DISCONN_SERVER_TERMINATED_CLOSED`, which the client maps to /// `ML_ERROR_GRACEFUL_TERMINATION` *provided it has seen a frame* — true for any real session, /// and the reason this reads as "the app quit" rather than an error. -fn termination_plaintext(scheme: &Scheme) -> Vec { +fn termination_plaintext() -> Vec { + /// `packetTypesGen7Enc[IDX_TERMINATION]` — see the version gate above. + const TERMINATION: u16 = 0x0109; /// `NVST_DISCONN_SERVER_TERMINATED_CLOSED` — a deliberate, graceful host-side end. const GRACEFUL: u32 = 0x8003_0023; - let ty: u16 = match scheme.nonce { - NonceKind::V2 { .. } => 0x0109, - NonceKind::LegacyLowByte | NonceKind::Legacy16Seq { .. } => 0x0100, - }; let mut pt = Vec::with_capacity(8); - pt.extend_from_slice(&ty.to_le_bytes()); + pt.extend_from_slice(&TERMINATION.to_le_bytes()); pt.extend_from_slice(&4u16.to_le_bytes()); // length = the reason that follows pt.extend_from_slice(&GRACEFUL.to_be_bytes()); // big-endian: the extended branch pt @@ -745,31 +748,32 @@ mod tests { /// failed test. Pinned against moonlight-common-c `ControlStream.c`. #[test] fn termination_plaintext_wire_layout() { - let legacy = super::Scheme { - key_rev: false, - nonce: super::NonceKind::LegacyLowByte, - tag_first: true, - aad: super::Aad::None, - }; - let pt = super::termination_plaintext(&legacy); + let pt = super::termination_plaintext(); assert_eq!(pt.len(), 8); - // We advertise no encryption, so this client is on `packetTypesGen7`. - assert_eq!(&pt[0..2], &0x0100u16.to_le_bytes()); + // The ENCRYPTED table's entry — which is the one every client reads, see below. + assert_eq!(&pt[0..2], &0x0109u16.to_le_bytes()); assert_eq!(&pt[2..4], &4u16.to_le_bytes()); - // The reason is BIG-endian — the client's >=6-byte "extended" branch. + // The reason is BIG-endian: the client's >=6-byte "extended" branch. assert_eq!(&pt[4..8], &0x8003_0023u32.to_be_bytes()); + } - // A V2-nonce client reads the encrypted table instead, where termination moved to 0x0109. - let v2 = super::Scheme { - nonce: super::NonceKind::V2 { - seq_be: false, - marker: [b'C', b'C'], - }, - ..legacy - }; - assert_eq!( - &super::termination_plaintext(&v2)[0..2], - &0x0109u16.to_le_bytes() + /// The termination type above is only correct because of the version we advertise: + /// moonlight-common-c sets `encryptedControlStream = APP_VERSION_AT_LEAST(7, 1, 431)`, and that + /// alone decides whether the client reads `packetTypesGen7Enc` (termination `0x0109`) or + /// `packetTypesGen7` (`0x0100`). Drop below that version and the message silently stops being + /// understood — the stream would end as an error again, with nothing failing here to say why. + #[test] + fn advertised_version_keeps_the_client_on_the_encrypted_packet_table() { + let q: Vec = super::super::APP_VERSION + .split('.') + .map(|p| p.parse().unwrap_or(0)) + .collect(); + let at_least = q[0] > 7 || (q[0] == 7 && (q[1] > 1 || (q[1] == 1 && q[2] >= 431))); + assert!( + at_least, + "APP_VERSION {} is below 7.1.431, so clients read the PLAIN packet table and \ + termination must become 0x0100", + super::super::APP_VERSION ); }