feat: M0 capture→encode pipeline + M2 GameStream host (pairing, RTSP, video)

M0 (lumen-host) — verified on NVIDIA RTX 5070 Ti / Ubuntu 25.10:
headless wlroots → xdg ScreenCast portal → PipeWire → NVENC HEVC → playable file,
with each access unit round-tripped through a lumen_core host↔client Session
(FEC + packetize + reassemble), 0 mismatches.
- capture.rs: SyntheticCapturer + portal capture (ashpd 0.13 + pipewire 0.9), format-aware
- encode/linux.rs: NVENC via ffmpeg-next 7 (BGRx/RGB → rgb0, no host-side swscale)
- m0.rs: capture→encode→file + lumen-core loopback verification

M2 P1 (lumen-host gamestream/) — a stock Moonlight client pairs + launches, verified live:
- mDNS _nvstream._tcp + nvhttp /serverinfo (HTTP 47989, mutual-TLS HTTPS 47984)
- 4-phase pairing: PIN→AES-128-ECB / SHA-256 / RSA-PKCS1v15 / X.509, custom rustls
  ClientCertVerifier for the mutual-TLS pairchallenge
- /applist, /launch (rikey/rikeyid/mode), hand-rolled RTSP (OPTIONS/DESCRIBE/SETUP×3/
  ANNOUNCE/PLAY, one-request-per-TCP-connection per moonlight-common-c's read-to-EOF)
- video.rs: GameStream RTP + NV_VIDEO_PACKET wire packetizer, data-shards-only (0% FEC,
  clean-LAN), unit-tested (single/multi-block)

Docs: docs/m2-plan.md (phased plan) + docs/research/ (ground-truth protocol spec).
Bootstrap/setup updated for the verified path (libnvidia-gl, render/video groups, GPU
EGL, pipewire 0.9). Workspace clippy-clean, tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 07:14:59 +00:00
parent 8b0172d793
commit ab6dda2e5f
26 changed files with 5148 additions and 123 deletions
+75
View File
@@ -0,0 +1,75 @@
//! The host's self-signed RSA-2048 identity: the cert returned to clients as `plaincert`
//! during pairing AND presented as the TLS server cert on 47984 (Moonlight pins it). The
//! cert's own X.509 signature bytes are an input to the pairing hashes, so we extract them.
use super::config_dir;
use anyhow::{anyhow, Context, Result};
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey;
use rsa::RsaPrivateKey;
use sha2::Sha256;
use std::fs;
pub struct ServerIdentity {
/// PEM of the cert (returned hex-encoded as `plaincert`; also the TLS server cert).
pub cert_pem: String,
/// PKCS#8 PEM of the private key (TLS server key).
pub key_pem: String,
/// The cert's X.509 `signatureValue` bytes — bound into the pairing challenge hashes.
pub signature: Vec<u8>,
/// RSA-PKCS1v15-SHA256 signer over the host key (the pairing `sign256`).
pub signing_key: SigningKey<Sha256>,
}
impl ServerIdentity {
pub fn load_or_create() -> Result<ServerIdentity> {
let dir = config_dir();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
let (cert_pem, key_pem) = match (
fs::read_to_string(&cert_path),
fs::read_to_string(&key_path),
) {
(Ok(c), Ok(k)) if !c.trim().is_empty() && !k.trim().is_empty() => (c, k),
_ => {
let (c, k) = generate()?;
fs::create_dir_all(&dir).ok();
fs::write(&cert_path, &c)
.with_context(|| format!("write {}", cert_path.display()))?;
fs::write(&key_path, &k)
.with_context(|| format!("write {}", key_path.display()))?;
tracing::info!(path = %cert_path.display(), "generated lumen host certificate (RSA-2048)");
(c, k)
}
};
let priv_key = RsaPrivateKey::from_pkcs8_pem(&key_pem).context("parse host private key")?;
let signing_key = SigningKey::<Sha256>::new(priv_key);
let signature = cert_signature(&cert_pem)?;
Ok(ServerIdentity {
cert_pem,
key_pem,
signature,
signing_key,
})
}
}
fn generate() -> Result<(String, String)> {
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_RSA_SHA256).context("rcgen RSA keygen")?;
let mut params = rcgen::CertificateParams::new(Vec::<String>::new()).context("cert params")?;
params
.distinguished_name
.push(rcgen::DnType::CommonName, "lumen");
params.not_before = rcgen::date_time_ymd(2020, 1, 1);
params.not_after = rcgen::date_time_ymd(2040, 1, 1);
let cert = params.self_signed(&key).context("self-sign cert")?;
Ok((cert.pem(), key.serialize_pem()))
}
/// Extract the X.509 `signatureValue` bytes from a cert PEM.
fn cert_signature(cert_pem: &str) -> Result<Vec<u8>> {
let (_, pem) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes())
.map_err(|e| anyhow!("parse cert pem: {e}"))?;
let x509 = pem.parse_x509().context("parse x509")?;
Ok(x509.signature_value.data.to_vec())
}
@@ -0,0 +1,60 @@
//! Pairing crypto primitives (control plane only — distinct from `lumen_core`'s AES-GCM
//! data-plane sealing). GameStream pairing uses: AES-128-**ECB** with **no padding**,
//! SHA-256 (host appversion major ≥ 7), and RSA-PKCS1v15-SHA256 signatures. See the
//! `serverinfo + pairing` section of `docs/research/gamestream-protocol-research.json`.
use aes::cipher::generic_array::GenericArray;
use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit};
use aes::Aes128;
use rand::RngCore;
use sha2::{Digest, Sha256};
/// `n` cryptographically-random bytes.
pub fn random<const N: usize>() -> [u8; N] {
let mut b = [0u8; N];
rand::thread_rng().fill_bytes(&mut b);
b
}
/// SHA-256 over the concatenation of `parts`.
pub fn sha256(parts: &[&[u8]]) -> [u8; 32] {
let mut h = Sha256::new();
for p in parts {
h.update(p);
}
h.finalize().into()
}
/// The PIN-derived AES-128 key: `SHA-256(salt || pin)[..16]` (salt first, PIN as ASCII).
pub fn pin_key(salt: &[u8; 16], pin: &str) -> [u8; 16] {
let d = sha256(&[salt, pin.as_bytes()]);
let mut k = [0u8; 16];
k.copy_from_slice(&d[..16]);
k
}
/// AES-128-ECB encrypt, no padding: input is zero-extended to a 16-byte multiple.
pub fn ecb_encrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut out = data.to_vec();
let rem = out.len() % 16;
if rem != 0 {
out.resize(out.len() + (16 - rem), 0);
}
for chunk in out.chunks_mut(16) {
cipher.encrypt_block(GenericArray::from_mut_slice(chunk));
}
out
}
/// AES-128-ECB decrypt, no padding: trailing bytes past the last whole block are ignored.
pub fn ecb_decrypt(key: &[u8; 16], data: &[u8]) -> Vec<u8> {
let cipher = Aes128::new(GenericArray::from_slice(key));
let mut out = Vec::with_capacity(data.len());
for chunk in data.chunks_exact(16) {
let mut block = *GenericArray::from_slice(chunk);
cipher.decrypt_block(&mut block);
out.extend_from_slice(&block);
}
out
}
+37
View File
@@ -0,0 +1,37 @@
//! mDNS advertisement of `_nvstream._tcp.local.` so Moonlight auto-discovers the host.
//! (Manual "add host by IP" also works as a fallback, which is what we test with first.)
use super::Host;
use anyhow::{Context, Result};
use mdns_sd::{ServiceDaemon, ServiceInfo};
use std::collections::HashMap;
/// Holds the mDNS daemon; dropping it unregisters the service.
pub struct Advert {
_daemon: ServiceDaemon,
}
pub fn advertise(host: &Host) -> Result<Advert> {
let daemon = ServiceDaemon::new().context("create mDNS daemon")?;
let host_name = format!("{}.local.", host.hostname);
// No TXT records are required for Moonlight discovery; it resolves the A record and then
// GETs /serverinfo for capabilities.
let props: HashMap<String, String> = HashMap::new();
let service = ServiceInfo::new(
"_nvstream._tcp.local.",
&host.hostname,
&host_name,
host.local_ip,
host.http_port,
props,
)
.context("build mDNS ServiceInfo")?;
daemon.register(service).context("register mDNS service")?;
tracing::info!(
service = "_nvstream._tcp",
port = host.http_port,
host = %host_name,
"mDNS advertising"
);
Ok(Advert { _daemon: daemon })
}
+151
View File
@@ -0,0 +1,151 @@
//! GameStream (P1) control plane — what a stock Moonlight/Artemis client talks to around
//! the media streams: mDNS discovery, the nvhttp serverinfo + pairing HTTP(S) API, RTSP,
//! and the ENet control stream. `tokio`/`axum` live here (control plane, I/O-bound — never
//! the per-frame hot path; that is `lumen_core`'s P1 wire codec). See `docs/m2-plan.md`.
//!
//! Status: P1.1 — mDNS `_nvstream._tcp` advertisement + `/serverinfo`. Pairing, RTSP, and
//! the media streams follow (see the M2 task list / plan).
mod cert;
mod crypto;
mod mdns;
mod nvhttp;
mod pairing;
mod rtsp;
mod serverinfo;
mod tls;
mod video;
use anyhow::{Context, Result};
use std::net::{IpAddr, Ipv4Addr, UdpSocket};
use std::path::PathBuf;
use std::sync::Arc;
/// nvhttp ports (Moonlight derives all stream ports by offset from the HTTP base 47989).
pub const HTTP_PORT: u16 = 47989;
pub const HTTPS_PORT: u16 = 47984;
pub const RTSP_PORT: u16 = 48010;
pub const VIDEO_PORT: u16 = 47998;
pub const CONTROL_PORT: u16 = 47999;
pub const AUDIO_PORT: u16 = 48000;
/// Advertised host version. Major ≥ 7 tells Moonlight to use SHA-256 for pairing.
pub const APP_VERSION: &str = "7.1.431.-1";
pub const GFE_VERSION: &str = "3.23.0.74";
/// Codec support bitmask: 3=H264, 259=+HEVC, 3843=+AV1 (we encode HEVC/H264/AV1 via NVENC).
pub const SERVER_CODEC_MODE_SUPPORT: u32 = 3843;
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
pub struct Host {
pub hostname: String,
/// Stable per-host id (persisted), echoed in serverinfo + matched on pairing.
pub uniqueid: String,
pub local_ip: IpAddr,
pub http_port: u16,
pub https_port: u16,
// Pairing state (server cert, paired client certs) lands in the next P1.1 slice.
}
impl Host {
pub fn detect() -> Result<Host> {
Ok(Host {
hostname: hostname_string(),
uniqueid: load_or_create_uniqueid()?,
local_ip: primary_local_ip().unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST)),
http_port: HTTP_PORT,
https_port: HTTPS_PORT,
})
}
}
/// The stream parameters a client passes at `/launch`, shared with the RTSP + media stages.
#[derive(Clone, Copy, Debug)]
pub struct LaunchSession {
/// AES-128 key for the RTSP/control/video/audio planes (from `rikey`).
pub gcm_key: [u8; 16],
/// `rikeyid` — seeds the per-stream GCM IVs.
pub rikeyid: i32,
pub width: u32,
pub height: u32,
pub fps: u32,
}
/// Shared control-plane state used as the axum app state.
pub struct AppState {
pub host: Host,
pub identity: cert::ServerIdentity,
pub pairing: pairing::Pairing,
/// Pinned (paired) client certificate DERs — the post-pair allow-list.
pub paired: std::sync::Mutex<Vec<Vec<u8>>>,
/// The active launch session (set by `/launch`, consumed by RTSP/media).
pub launch: std::sync::Mutex<Option<LaunchSession>>,
}
/// Run the GameStream control plane (blocks): mDNS advertisement + the nvhttp servers.
pub fn serve() -> Result<()> {
let host = Host::detect()?;
let identity = cert::ServerIdentity::load_or_create().context("host certificate")?;
let state = Arc::new(AppState {
host,
identity,
pairing: pairing::Pairing::new(),
paired: std::sync::Mutex::new(Vec::new()),
launch: std::sync::Mutex::new(None),
});
tracing::info!(
hostname = %state.host.hostname,
uniqueid = %state.host.uniqueid,
ip = %state.host.local_ip,
"lumen GameStream host (P1.1: serverinfo + pairing + mDNS)"
);
let rt = tokio::runtime::Runtime::new().context("build tokio runtime")?;
rt.block_on(async move {
// rustls needs a process-wide crypto provider before any TLS config is built.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let _advert = mdns::advertise(&state.host).context("mDNS advertise")?;
rtsp::spawn(state.clone()).context("start RTSP server")?;
nvhttp::run(state).await
})
}
/// `~/.config/lumen`, created on demand — host identity + (later) pairing state live here.
fn config_dir() -> PathBuf {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
base.join("lumen")
}
fn hostname_string() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "lumen-host".to_string())
}
/// Load the persisted host uniqueid, or mint one (from the kernel UUID source) and store it.
fn load_or_create_uniqueid() -> Result<String> {
let path = config_dir().join("uniqueid");
if let Ok(s) = std::fs::read_to_string(&path) {
let t = s.trim();
if !t.is_empty() {
return Ok(t.to_string());
}
}
let id = std::fs::read_to_string("/proc/sys/kernel/random/uuid")
.map(|u| u.trim().replace('-', ""))
.unwrap_or_else(|_| format!("{:016x}{:016x}", std::process::id(), HTTP_PORT));
std::fs::create_dir_all(config_dir()).ok();
std::fs::write(&path, &id).with_context(|| format!("write {}", path.display()))?;
Ok(id)
}
/// Best-effort primary LAN IP: open a UDP socket "toward" a public address and read the
/// local address the OS would route through. No packets are actually sent.
fn primary_local_ip() -> Option<IpAddr> {
let sock = UdpSocket::bind("0.0.0.0:0").ok()?;
sock.connect("8.8.8.8:80").ok()?;
sock.local_addr().ok().map(|a| a.ip())
}
+228
View File
@@ -0,0 +1,228 @@
//! The nvhttp servers: plain HTTP on 47989 and mutual-TLS on 47984. Serves `/serverinfo`,
//! the `/pair` flow, `/applist`, and `/launch`/`/resume`/`/cancel`, plus a lumen-only
//! `/pin` endpoint to deliver the Moonlight-displayed PIN. Over HTTPS the client is
//! mutual-TLS-authenticated, so `/serverinfo` reports `PairStatus=1` there.
use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_PORT};
use anyhow::{anyhow, Context, Result};
use axum::{
extract::{Query, State},
http::header,
response::IntoResponse,
routing::get,
Extension, Router,
};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
/// Which listener a request arrived on — HTTPS means a mutual-TLS-authenticated client.
#[derive(Clone, Copy)]
struct Https(bool);
pub async fn run(state: Arc<AppState>) -> Result<()> {
// Mutual-TLS: request + verify the client cert (Moonlight presents one for the
// post-pairing pairchallenge + all post-pair endpoints).
let tls = axum_server::tls_rustls::RustlsConfig::from_config(super::tls::server_config(
&state.identity.cert_pem,
&state.identity.key_pem,
)?);
let http_addr = SocketAddr::from(([0, 0, 0, 0], HTTP_PORT));
let https_addr = SocketAddr::from(([0, 0, 0, 0], HTTPS_PORT));
tracing::info!(%http_addr, %https_addr, "nvhttp listening (serverinfo + pair + launch)");
let http = axum_server::bind(http_addr).serve(router(state.clone(), false).into_make_service());
let https =
axum_server::bind_rustls(https_addr, tls).serve(router(state, true).into_make_service());
tokio::try_join!(async { http.await.context("nvhttp HTTP server") }, async {
https.await.context("nvhttp HTTPS server")
},)?;
Ok(())
}
fn router(state: Arc<AppState>, https: bool) -> Router {
Router::new()
.route("/serverinfo", get(h_serverinfo))
.route("/pair", get(h_pair))
.route("/pin", get(h_pin))
.route("/applist", get(h_applist))
.route("/launch", get(h_launch))
.route("/resume", get(h_resume))
.route("/cancel", get(h_cancel))
.layer(Extension(Https(https)))
.with_state(state)
}
fn xml(body: String) -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/xml")], body)
}
async fn h_serverinfo(
State(st): State<Arc<AppState>>,
Extension(Https(https)): Extension<Https>,
) -> impl IntoResponse {
// Over the mutual-TLS port the peer is an authenticated (paired) client → PairStatus=1.
xml(serverinfo::serverinfo_xml(&st.host, https))
}
async fn h_pin(
State(st): State<Arc<AppState>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
match q.get("pin").filter(|p| !p.is_empty()) {
Some(pin) => {
st.pairing.pin.submit(pin.clone());
"PIN accepted\n".to_string()
}
None => "usage: GET /pin?pin=NNNN\n".to_string(),
}
}
async fn h_applist(State(_st): State<Arc<AppState>>) -> impl IntoResponse {
// One app for now: the headless desktop (the wlroots virtual output).
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>Desktop</AppTitle>\n<ID>1</ID>\n</App>\n</root>\n".to_string())
}
async fn h_launch(
State(st): State<Arc<AppState>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
match launch(&st, &q) {
Ok(session) => {
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
h = session.height,
fps = session.fps,
rikeyid = session.rikeyid,
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
);
xml(session_url_xml(&st, "gamesession"))
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml())
}
}
}
async fn h_resume(State(st): State<Arc<AppState>>) -> impl IntoResponse {
if st.launch.lock().unwrap().is_some() {
xml(session_url_xml(&st, "resume"))
} else {
xml(error_xml())
}
}
async fn h_cancel(State(st): State<Arc<AppState>>) -> impl IntoResponse {
*st.launch.lock().unwrap() = None;
tracing::info!("cancel — launch session cleared");
xml("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
}
/// Parse the `/launch` query (rikey/rikeyid/mode) into a [`LaunchSession`].
fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession> {
let rikey = q.get("rikey").ok_or_else(|| anyhow!("missing rikey"))?;
let key_bytes = hex::decode(rikey).context("rikey hex")?;
if key_bytes.len() < 16 {
return Err(anyhow!("rikey too short"));
}
let mut gcm_key = [0u8; 16];
gcm_key.copy_from_slice(&key_bytes[..16]);
// rikeyid is a signed 32-bit int (negative values wrap to a big-endian u32 IV later).
let rikeyid: i32 = q.get("rikeyid").and_then(|s| s.parse().ok()).unwrap_or(0);
let (width, height, fps) = q
.get("mode")
.and_then(|m| parse_mode(m))
.unwrap_or((1920, 1080, 60));
Ok(LaunchSession {
gcm_key,
rikeyid,
width,
height,
fps,
})
}
/// `"1920x1080x60"` → `(1920, 1080, 60)`.
fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
let mut it = mode.split('x');
let w = it.next()?.parse().ok()?;
let h = it.next()?.parse().ok()?;
let fps = it.next()?.parse().ok()?;
Some((w, h, fps))
}
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
st.host.local_ip
)
}
async fn h_pair(
State(st): State<Arc<AppState>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let uniqueid = q.get("uniqueid").cloned().unwrap_or_default();
let phrase = q.get("phrase").map(String::as_str);
let step = phrase
.filter(|p| *p == "getservercert" || *p == "pairchallenge")
.or_else(|| {
[
"clientchallenge",
"serverchallengeresp",
"clientpairingsecret",
]
.into_iter()
.find(|k| q.contains_key(*k))
})
.unwrap_or("?");
tracing::info!(uniqueid, step, "pair request");
let result = if phrase == Some("getservercert") {
match (q.get("salt"), q.get("clientcert")) {
(Some(salt), Some(cc)) => {
st.pairing
.getservercert(&st.identity, &uniqueid, salt, cc)
.await
}
_ => Ok(pair_error_xml()),
}
} else if phrase == Some("pairchallenge") {
// Reached only over the TLS port with the pinned host cert; the handshake is the
// proof, so acknowledge success.
Ok(paired_ok_xml())
} else if let Some(v) = q.get("clientchallenge") {
st.pairing.clientchallenge(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("serverchallengeresp") {
st.pairing.serverchallengeresp(&st.identity, &uniqueid, v)
} else if let Some(v) = q.get("clientpairingsecret") {
st.pairing.clientpairingsecret(&uniqueid, v, &st.paired)
} else {
Ok(pair_error_xml())
};
let body = result.unwrap_or_else(|e| {
tracing::warn!(error = %format!("{e:#}"), uniqueid, "pair handler error");
pair_error_xml()
});
xml(body)
}
fn paired_ok_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>1</paired></root>\n"
.to_string()
}
fn pair_error_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><paired>0</paired></root>\n"
.to_string()
}
fn error_xml() -> String {
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"400\"></root>\n".to_string()
}
+251
View File
@@ -0,0 +1,251 @@
//! The 4-phase GameStream pairing state machine (over HTTP), keyed by `uniqueid`. Proves
//! both sides know the PIN (via the SHA-256(salt||pin) AES-ECB key) and own their certs
//! (RSA signatures), then pins the client cert. The final `pairchallenge` happens over
//! HTTPS (handled in `nvhttp`). Byte-exact spec: `docs/research/…-research.json`.
use super::cert::ServerIdentity;
use super::crypto;
use anyhow::{anyhow, bail, Context, Result};
use rsa::pkcs1v15::{Signature, VerifyingKey};
use rsa::pkcs8::DecodePublicKey;
use rsa::signature::{SignatureEncoding, Signer, Verifier};
use rsa::RsaPublicKey;
use sha2::Sha256;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::Notify;
/// Out-of-band PIN delivery. Moonlight generates + displays a PIN; the user submits it
/// (here via `GET /pin?pin=NNNN`). `getservercert` parks until a PIN arrives.
pub struct PinGate {
pin: Mutex<Option<String>>,
notify: Notify,
}
impl PinGate {
fn new() -> Self {
PinGate {
pin: Mutex::new(None),
notify: Notify::new(),
}
}
pub fn submit(&self, pin: String) {
*self.pin.lock().unwrap() = Some(pin);
self.notify.notify_waiters();
}
async fn take(&self, timeout: Duration) -> Option<String> {
let deadline = tokio::time::Instant::now() + timeout;
loop {
if let Some(p) = self.pin.lock().unwrap().take() {
return Some(p);
}
if tokio::time::timeout_at(deadline, self.notify.notified())
.await
.is_err()
{
return None;
}
}
}
}
/// Per-client pairing session carried across the 4 separate HTTP GETs.
struct Session {
aes_key: [u8; 16],
client_cert_der: Vec<u8>,
client_cert_sig: Vec<u8>,
client_pubkey: RsaPublicKey,
serversecret: [u8; 16],
server_challenge: [u8; 16],
/// The client's phase-3 hash, recomputed + checked in phase 4.
client_hash: Vec<u8>,
}
pub struct Pairing {
sessions: Mutex<HashMap<String, Session>>,
pub pin: PinGate,
}
impl Pairing {
pub fn new() -> Self {
Pairing {
sessions: Mutex::new(HashMap::new()),
pin: PinGate::new(),
}
}
/// Phase 1: store the client cert, await the PIN, derive the AES key, return our cert.
pub async fn getservercert(
&self,
id: &ServerIdentity,
uniqueid: &str,
salt_hex: &str,
clientcert_hex: &str,
) -> Result<String> {
let salt_bytes = hex::decode(salt_hex).context("salt hex")?;
if salt_bytes.len() < 16 {
bail!("salt too short");
}
let mut salt = [0u8; 16];
salt.copy_from_slice(&salt_bytes[..16]);
let pem_bytes = hex::decode(clientcert_hex).context("clientcert hex")?;
let (der, sig, pubkey) = parse_client_cert(&pem_bytes)?;
tracing::info!(
uniqueid,
"pairing phase 1 (getservercert) — awaiting PIN: submit `GET /pin?pin=NNNN`"
);
let pin = self
.pin
.take(Duration::from_secs(300))
.await
.ok_or_else(|| anyhow!("no PIN submitted within 300s"))?;
let aes_key = crypto::pin_key(&salt, &pin);
self.sessions.lock().unwrap().insert(
uniqueid.to_string(),
Session {
aes_key,
client_cert_der: der,
client_cert_sig: sig,
client_pubkey: pubkey,
serversecret: [0; 16],
server_challenge: [0; 16],
client_hash: Vec::new(),
},
);
tracing::info!(
uniqueid,
"pairing phase 1 — PIN accepted, returning host cert"
);
let inner = format!(
"<plaincert>{}</plaincert>",
hex::encode(id.cert_pem.as_bytes())
);
Ok(paired_xml(&inner, true))
}
/// Phase 2: decrypt the client challenge, return our hash + server challenge.
pub fn clientchallenge(
&self,
id: &ServerIdentity,
uniqueid: &str,
hexv: &str,
) -> Result<String> {
let mut map = self.sessions.lock().unwrap();
let s = map
.get_mut(uniqueid)
.ok_or_else(|| anyhow!("no pairing session"))?;
let enc = hex::decode(hexv).context("clientchallenge hex")?;
let client_challenge = crypto::ecb_decrypt(&s.aes_key, &enc);
if client_challenge.len() < 16 {
bail!("short client challenge");
}
s.serversecret = crypto::random();
s.server_challenge = crypto::random();
let server_hash =
crypto::sha256(&[&client_challenge[..16], &id.signature, &s.serversecret]);
let mut plain = Vec::with_capacity(48);
plain.extend_from_slice(&server_hash);
plain.extend_from_slice(&s.server_challenge);
let resp = crypto::ecb_encrypt(&s.aes_key, &plain);
let inner = format!(
"<challengeresponse>{}</challengeresponse>",
hex::encode(resp)
);
Ok(paired_xml(&inner, true))
}
/// Phase 3: store the client's hash, return our RSA-signed serversecret.
pub fn serverchallengeresp(
&self,
id: &ServerIdentity,
uniqueid: &str,
hexv: &str,
) -> Result<String> {
let mut map = self.sessions.lock().unwrap();
let s = map
.get_mut(uniqueid)
.ok_or_else(|| anyhow!("no pairing session"))?;
let enc = hex::decode(hexv).context("serverchallengeresp hex")?;
let client_hash = crypto::ecb_decrypt(&s.aes_key, &enc);
if client_hash.len() < 32 {
bail!("short challenge response");
}
s.client_hash = client_hash[..32].to_vec();
let sig: Signature = id.signing_key.sign(&s.serversecret);
let mut secret = Vec::with_capacity(16 + 256);
secret.extend_from_slice(&s.serversecret);
secret.extend_from_slice(&sig.to_vec());
let inner = format!("<pairingsecret>{}</pairingsecret>", hex::encode(secret));
Ok(paired_xml(&inner, true))
}
/// Phase 4: verify the client knew the PIN (hash match) and owns its cert (RSA verify);
/// on success, pin the client cert.
pub fn clientpairingsecret(
&self,
uniqueid: &str,
hexv: &str,
paired_store: &Mutex<Vec<Vec<u8>>>,
) -> Result<String> {
let mut map = self.sessions.lock().unwrap();
let s = map
.get_mut(uniqueid)
.ok_or_else(|| anyhow!("no pairing session"))?;
let data = hex::decode(hexv).context("clientpairingsecret hex")?;
if data.len() < 16 {
bail!("short pairing secret");
}
let client_secret = &data[..16];
let client_sig = &data[16..];
let expected = crypto::sha256(&[&s.server_challenge, &s.client_cert_sig, client_secret]);
let hash_ok = expected[..] == s.client_hash[..];
let sig_ok = verify256(&s.client_pubkey, client_secret, client_sig).is_ok();
if hash_ok && sig_ok {
paired_store.lock().unwrap().push(s.client_cert_der.clone());
tracing::info!(uniqueid, "pairing phase 4 — SUCCESS, client cert pinned");
Ok(paired_xml("", true))
} else {
tracing::warn!(
uniqueid,
hash_ok,
sig_ok,
"pairing phase 4 — FAILED (PIN/cert)"
);
map.remove(uniqueid);
Ok(paired_xml("", false))
}
}
}
fn verify256(pubkey: &RsaPublicKey, msg: &[u8], sig: &[u8]) -> Result<()> {
let vk = VerifyingKey::<Sha256>::new(pubkey.clone());
let signature = Signature::try_from(sig).context("parse client signature")?;
vk.verify(msg, &signature)
.context("verify client signature")?;
Ok(())
}
fn parse_client_cert(pem_bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>, RsaPublicKey)> {
let (_, pem) =
x509_parser::pem::parse_x509_pem(pem_bytes).map_err(|e| anyhow!("client cert pem: {e}"))?;
let der = pem.contents.clone();
let x509 = pem.parse_x509().context("parse client x509")?;
let sig = x509.signature_value.data.to_vec();
let pubkey =
RsaPublicKey::from_public_key_der(x509.public_key().raw).context("client rsa pubkey")?;
Ok((der, sig, pubkey))
}
/// `<root status_code="200"><paired>0|1</paired> inner </root>`.
fn paired_xml(inner: &str, paired: bool) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<paired>{}</paired>\n{}</root>\n",
u8::from(paired),
inner
)
}
+266
View File
@@ -0,0 +1,266 @@
//! The GameStream RTSP handshake (TCP 48010). Hand-rolled because GameStream's RTSP is
//! non-standard (streamid= targets, the literal `DEADBEEFCAFE` session, the X-SS-* headers)
//! and off-the-shelf RTSP crates assume standard semantics. Sequence Moonlight drives:
//! OPTIONS → DESCRIBE → SETUP(audio/video/control) → ANNOUNCE → PLAY. ANNOUNCE carries the
//! negotiated stream config; PLAY is where the media stages start (P1.3+).
//!
//! Runs on its own native thread (control-plane setup, not the per-frame hot path), one
//! thread per connection. Plaintext only for now (encryption is negotiated; P1.5).
use super::{AppState, AUDIO_PORT, CONTROL_PORT, RTSP_PORT, VIDEO_PORT};
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
/// Opaque per-session payload the client echoes as its first UDP datagram (port-learning).
const PING_PAYLOAD: &str = "0011223344556677";
/// Bind 48010 and accept RTSP connections on a dedicated thread.
pub fn spawn(state: Arc<AppState>) -> Result<()> {
let listener = TcpListener::bind(("0.0.0.0", RTSP_PORT))
.with_context(|| format!("bind RTSP {RTSP_PORT}"))?;
tracing::info!(port = RTSP_PORT, "RTSP listening");
std::thread::Builder::new()
.name("lumen-rtsp".into())
.spawn(move || {
for conn in listener.incoming() {
match conn {
Ok(stream) => {
let st = state.clone();
std::thread::spawn(move || {
if let Err(e) = handle_conn(stream, st) {
tracing::warn!(error = %format!("{e:#}"), "RTSP connection ended");
}
});
}
Err(e) => tracing::warn!(error = %e, "RTSP accept failed"),
}
}
})
.context("spawn RTSP thread")?;
Ok(())
}
struct Request {
method: String,
uri: String,
cseq: String,
head: String,
body: String,
}
fn handle_conn(mut stream: TcpStream, state: Arc<AppState>) -> Result<()> {
let peer = stream.peer_addr().ok();
let mut buf: Vec<u8> = Vec::new();
// GameStream RTSP is one request per TCP connection: moonlight-common-c reads the
// response until EOF, so we answer one message and close the connection (which signals
// the end of the response). Session state lives in `AppState`, not the connection.
if let Some(req) = read_message(&mut stream, &mut buf)? {
tracing::info!(
method = %req.method, cseq = %req.cseq,
"RTSP {} | {}", req.head.replace("\r\n", " | "),
if req.body.is_empty() { String::new() } else { format!("body: {}", req.body.replace("\r\n", " | ")) }
);
let resp = handle_request(&req, &state);
stream.write_all(resp.as_bytes()).context("RTSP write")?;
stream.flush().ok();
// Close (FIN after the flushed response) so the client detects end-of-response.
let _ = stream.shutdown(std::net::Shutdown::Both);
}
let _ = peer;
Ok(())
}
/// Read one complete RTSP message (headers + any Content-Length body) from the stream,
/// buffering across reads and leaving any pipelined remainder in `buf`.
fn read_message(stream: &mut TcpStream, buf: &mut Vec<u8>) -> Result<Option<Request>> {
loop {
if let Some(end) = find_subslice(buf, b"\r\n\r\n") {
let head = std::str::from_utf8(&buf[..end]).context("RTSP header utf8")?;
let content_len = header_value(head, "content-length")
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(0);
let total = end + 4 + content_len;
if buf.len() < total {
// headers complete but body still arriving — read more
} else {
let head = head.to_string();
let body = String::from_utf8_lossy(&buf[end + 4..total]).into_owned();
buf.drain(..total);
return Ok(Some(parse_request(&head, body)));
}
}
let mut tmp = [0u8; 8192];
let n = stream.read(&mut tmp).context("RTSP read")?;
if n == 0 {
return Ok(None); // peer closed
}
buf.extend_from_slice(&tmp[..n]);
}
}
fn parse_request(head: &str, body: String) -> Request {
let mut lines = head.split("\r\n");
let request_line = lines.next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let uri = parts.next().unwrap_or("").to_string();
let cseq = header_value(head, "cseq").unwrap_or("0").trim().to_string();
Request {
method,
uri,
cseq,
head: head.to_string(),
body,
}
}
fn handle_request(req: &Request, state: &AppState) -> String {
match req.method.as_str() {
"OPTIONS" => response(
&req.cseq,
&[("Public", "OPTIONS DESCRIBE SETUP ANNOUNCE PLAY TEARDOWN")],
None,
),
"DESCRIBE" => response(
&req.cseq,
&[("Content-Type", "application/sdp")],
Some(&describe_sdp()),
),
"SETUP" => {
let (port, extra_key) = match stream_type(&req.uri) {
Some("audio") => (AUDIO_PORT, "X-SS-Ping-Payload"),
Some("video") => (VIDEO_PORT, "X-SS-Ping-Payload"),
Some("control") => (CONTROL_PORT, "X-SS-Connect-Data"),
_ => return response_status("404 Not Found", &req.cseq, &[], None),
};
let transport = format!("server_port={port}");
response(
&req.cseq,
&[
("Session", "DEADBEEFCAFE;timeout = 90"),
("Transport", &transport),
(extra_key, PING_PAYLOAD),
],
None,
)
}
"ANNOUNCE" => {
let cfg = parse_announce(&req.body);
tracing::info!(
width = cfg
.get("x-nv-video[0].clientViewportWd")
.map(String::as_str)
.unwrap_or("?"),
height = cfg
.get("x-nv-video[0].clientViewportHt")
.map(String::as_str)
.unwrap_or("?"),
fps = cfg
.get("x-nv-video[0].maxFPS")
.map(String::as_str)
.unwrap_or("?"),
bitrate_kbps = cfg
.get("x-nv-vqos[0].bw.maximumBitrateKbps")
.map(String::as_str)
.unwrap_or("?"),
packet_size = cfg
.get("x-nv-video[0].packetSize")
.map(String::as_str)
.unwrap_or("?"),
codec = cfg
.get("x-nv-vqos[0].bitStreamFormat")
.map(String::as_str)
.unwrap_or("?"),
fec_pct = cfg
.get("x-nv-vqos[0].fec.repairPercent")
.map(String::as_str)
.unwrap_or("?"),
"RTSP ANNOUNCE — negotiated stream config"
);
// TODO(P1.3): map `cfg` → lumen_core::Config and stash it for the media stages.
let _ = state;
response(&req.cseq, &[], None)
}
"PLAY" => {
tracing::info!("RTSP PLAY — client ready; media streams would start here (P1.3)");
response(&req.cseq, &[("Session", "DEADBEEFCAFE;timeout = 90")], None)
}
"TEARDOWN" => response(&req.cseq, &[], None),
other => {
tracing::warn!(method = other, "RTSP unsupported method");
response_status("501 Not Implemented", &req.cseq, &[], None)
}
}
}
/// Host capability SDP returned by DESCRIBE. Advertises HEVC + AV1 and no encryption
/// (plaintext streams for now; P1.5 adds the negotiated AES paths).
fn describe_sdp() -> String {
// Line-oriented a=key:value, matching what moonlight-common-c scans for.
[
"a=x-ss-general.featureFlags:0",
"a=x-ss-general.encryptionSupported:0",
"a=x-ss-general.encryptionRequested:0",
"sprop-parameter-sets=AAAAAU", // HEVC capability indicator
"a=rtpmap:98 AV1/90000", // AV1 capability indicator
"",
]
.join("\r\n")
}
/// Parse an ANNOUNCE SDP body's `a=key:value` lines into a map.
fn parse_announce(body: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
for line in body.lines() {
if let Some(rest) = line.strip_prefix("a=") {
if let Some((k, v)) = rest.split_once(':') {
map.insert(k.to_string(), v.to_string());
}
}
}
map
}
/// Extract the stream type from a SETUP URI like `…/streamid=video/0/0`.
fn stream_type(uri: &str) -> Option<&str> {
let after = uri.split("streamid=").nth(1)?;
let token = after.split('/').next()?;
match token {
"audio" | "video" | "control" => Some(token),
_ => None,
}
}
fn response(cseq: &str, headers: &[(&str, &str)], body: Option<&str>) -> String {
response_status("200 OK", cseq, headers, body)
}
fn response_status(
status: &str,
cseq: &str,
headers: &[(&str, &str)],
body: Option<&str>,
) -> String {
let body = body.unwrap_or("");
let mut out = format!("RTSP/1.0 {status}\r\nCSeq: {cseq}\r\n");
for (k, v) in headers {
out.push_str(&format!("{k}: {v}\r\n"));
}
out.push_str(&format!("Content-Length: {}\r\n\r\n", body.len()));
out.push_str(body);
out
}
fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
fn header_value<'a>(head: &'a str, key_lower: &str) -> Option<&'a str> {
head.split("\r\n").find_map(|line| {
let (k, v) = line.split_once(':')?;
(k.trim().eq_ignore_ascii_case(key_lower)).then(|| v.trim_start())
})
}
@@ -0,0 +1,42 @@
//! The `/serverinfo` capability/status XML Moonlight GETs before pairing and each launch.
use super::{Host, APP_VERSION, GFE_VERSION, SERVER_CODEC_MODE_SUPPORT};
/// Build the `<root status_code="200">…</root>` serverinfo document. `https` selects the
/// paired-HTTPS variant (real MAC). Element names are case-sensitive and match what
/// moonlight-common-c parses.
pub fn serverinfo_xml(host: &Host, https: bool) -> String {
// MAC is hidden over plain HTTP; PairStatus reflects the pairing store once the HTTPS
// path carries per-client identity (a hardening follow-up — 0 for now).
let mac = if https {
"01:02:03:04:05:06"
} else {
"00:00:00:00:00:00"
};
// Over the mutual-TLS HTTPS port the peer is an authenticated (paired) client.
let pair_status = u8::from(https);
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<root status_code="200">
<hostname>{hostname}</hostname>
<appversion>{APP_VERSION}</appversion>
<GfeVersion>{GFE_VERSION}</GfeVersion>
<uniqueid>{uniqueid}</uniqueid>
<HttpsPort>{https_port}</HttpsPort>
<ExternalPort>{http_port}</ExternalPort>
<MaxLumaPixelsHEVC>1869449984</MaxLumaPixelsHEVC>
<mac>{mac}</mac>
<LocalIP>{local_ip}</LocalIP>
<ServerCodecModeSupport>{SERVER_CODEC_MODE_SUPPORT}</ServerCodecModeSupport>
<PairStatus>{pair_status}</PairStatus>
<currentgame>0</currentgame>
<state>SUNSHINE_SERVER_FREE</state>
</root>
"#,
hostname = host.hostname,
uniqueid = host.uniqueid,
https_port = host.https_port,
http_port = host.http_port,
local_ip = host.local_ip,
)
}
+99
View File
@@ -0,0 +1,99 @@
//! TLS for the HTTPS nvhttp port (47984). Moonlight does **mutual TLS** — it presents its
//! client cert and expects the server to request one — so a plain server-auth config makes
//! the post-pairing `pairchallenge` fail. This config requests the client cert and verifies
//! the client owns its key, but (for now) accepts any well-formed cert; enforcing the
//! paired allow-list (rejecting unpaired clients on /launch) is a follow-up hardening step.
use anyhow::{anyhow, Context, Result};
use rustls::client::danger::HandshakeSignatureValid;
use rustls::crypto::{verify_tls12_signature, verify_tls13_signature, CryptoProvider};
use rustls::pki_types::{CertificateDer, UnixTime};
use rustls::server::danger::{ClientCertVerified, ClientCertVerifier};
use rustls::{DigitallySignedStruct, DistinguishedName, ServerConfig, SignatureScheme};
use std::sync::Arc;
/// Requests + signature-checks the client cert but accepts any (the pairing handshake is
/// the real proof). Pinning to the paired set is a hardening follow-up.
#[derive(Debug)]
struct AcceptAnyClientCert {
provider: Arc<CryptoProvider>,
}
impl ClientCertVerifier for AcceptAnyClientCert {
fn offer_client_auth(&self) -> bool {
true
}
fn client_auth_mandatory(&self) -> bool {
true
}
fn root_hint_subjects(&self) -> &[DistinguishedName] {
&[]
}
fn verify_client_cert(
&self,
_end_entity: &CertificateDer,
_intermediates: &[CertificateDer],
_now: UnixTime,
) -> Result<ClientCertVerified, rustls::Error> {
Ok(ClientCertVerified::assertion())
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer,
dss: &DigitallySignedStruct,
) -> Result<HandshakeSignatureValid, rustls::Error> {
verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
/// Build a mutual-TLS `ServerConfig` presenting the host cert/key.
pub fn server_config(cert_pem: &str, key_pem: &str) -> Result<Arc<ServerConfig>> {
let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider());
let certs = rustls_pemfile::certs(&mut cert_pem.as_bytes())
.collect::<std::result::Result<Vec<_>, _>>()
.context("parse host cert PEM")?;
let key = rustls_pemfile::private_key(&mut key_pem.as_bytes())
.context("parse host key PEM")?
.ok_or_else(|| anyhow!("no private key in host key PEM"))?;
let verifier = Arc::new(AcceptAnyClientCert {
provider: provider.clone(),
});
let config = ServerConfig::builder_with_provider(provider)
.with_safe_default_protocol_versions()
.context("rustls protocol versions")?
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.context("rustls server cert")?;
Ok(Arc::new(config))
}
+216
View File
@@ -0,0 +1,216 @@
//! GameStream video wire packetization: an encoded access unit → UDP datagrams a stock
//! Moonlight client decodes. Each datagram is
//! `RTP_PACKET(12, big-endian) + reserved[4] + NV_VIDEO_PACKET(16, little-endian) + payload`
//! and the frame's bitstream is prefixed with an 8-byte `video_short_frame_header_t`, then
//! striped into ≤4 FEC blocks of ≤255 data shards. Byte-exact spec:
//! `docs/research/gamestream-protocol-research.json` (video plane).
//!
//! P1.3 sends **data shards only** (`fecPercentage = 0`): on a clean LAN the client has
//! every data shard and never runs ReedSolomon recovery, so we get a decodable frame
//! without matching Moonlight's `nanors` parity matrix (that interop work is P1.5). Plaintext
//! only (encryption negotiated off for now). This lives in lumen-host for fast iteration;
//! the wire codec moves into lumen-core (the P1 wire mode) once proven.
/// RTP `header` byte: version 2 (0x80) | extension (0x10) — Moonlight keys on the extension.
const RTP_HEADER_BYTE: u8 = 0x80 | 0x10;
const FLAG_PIC: u8 = 0x1;
const FLAG_EOF: u8 = 0x2;
const FLAG_SOF: u8 = 0x4;
const MULTI_FEC_FLAGS: u8 = 0x10;
const MAX_DATA_SHARDS_PER_BLOCK: usize = 255;
const MAX_FEC_BLOCKS: usize = 4;
/// Per-shard header: RTP(12) + reserved(4) + NV_VIDEO_PACKET(16).
const SHARD_HEADER: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FrameType {
Idr,
P,
}
/// Splits encoded access units into GameStream video datagrams.
pub struct VideoPacketizer {
/// Negotiated `packetSize` (ANNOUNCE `x-nv-video[0].packetSize`).
packet_size: usize,
/// Per-shard payload bytes = `blocksize - SHARD_HEADER`, `blocksize = packetSize + 16`.
payload_per_shard: usize,
frame_index: u32,
/// Monotonic per-stream packet counter (the RTP sequence / streamPacketIndex source).
seq: u32,
}
impl VideoPacketizer {
pub fn new(packet_size: usize) -> Self {
VideoPacketizer {
packet_size,
payload_per_shard: packet_size + 16 - SHARD_HEADER,
frame_index: 0,
seq: 0,
}
}
/// Packetize one encoded AU into wire datagrams (ready for UDP send).
pub fn packetize(
&mut self,
au: &[u8],
frame_type: FrameType,
timestamp_90k: u32,
) -> Vec<Vec<u8>> {
let frame_index = self.frame_index;
self.frame_index = self.frame_index.wrapping_add(1);
let pps = self.payload_per_shard;
// frame payload = 8-byte short frame header + the AU bitstream.
let total_len = 8 + au.len();
let last_payload_len = match total_len % pps {
0 => pps,
r => r,
};
let mut fp = Vec::with_capacity(total_len);
fp.extend_from_slice(&short_frame_header(frame_type, last_payload_len as u16));
fp.extend_from_slice(au);
let total_data = total_len.div_ceil(pps).max(1);
let n_blocks = total_data
.div_ceil(MAX_DATA_SHARDS_PER_BLOCK)
.clamp(1, MAX_FEC_BLOCKS);
let per_block = total_data.div_ceil(n_blocks);
let mut packets = Vec::with_capacity(total_data);
for b in 0..n_blocks {
let first = b * per_block;
let last = ((b + 1) * per_block).min(total_data);
if first >= last {
break;
}
let block_data_count = last - first;
for (fec_index, shard) in (first..last).enumerate() {
let start = shard * pps;
let end = (start + pps).min(fp.len());
let mut payload = vec![0u8; pps]; // last shard zero-padded
payload[..end - start].copy_from_slice(&fp[start..end]);
let mut flags = FLAG_PIC;
if shard == 0 {
flags |= FLAG_SOF;
}
if shard == total_data - 1 {
flags |= FLAG_EOF;
}
let multi_fec_blocks = ((b as u8) << 4) | (((n_blocks - 1) as u8) << 6);
// fecInfo: dataShards<<22 | fecIndex<<12 | fecPercentage<<4 (pct = 0).
let fec_info: u32 = ((block_data_count as u32) << 22) | ((fec_index as u32) << 12);
let seq = self.seq;
self.seq = self.seq.wrapping_add(1);
packets.push(build_packet(
seq,
timestamp_90k,
frame_index,
flags,
multi_fec_blocks,
fec_info,
&payload,
));
}
}
packets
}
}
/// 8-byte `video_short_frame_header_t` (little-endian), prefixed to the AU bitstream.
fn short_frame_header(frame_type: FrameType, last_payload_len: u16) -> [u8; 8] {
let mut h = [0u8; 8];
h[0] = 0x01; // headerType
h[1..3].copy_from_slice(&0u16.to_le_bytes()); // frame_processing_latency
h[3] = match frame_type {
FrameType::Idr => 2,
FrameType::P => 1,
};
h[4..6].copy_from_slice(&last_payload_len.to_le_bytes());
// h[6..8] unknown = 0
h
}
/// Build one wire datagram: RTP(BE) + reserved + NV_VIDEO_PACKET(LE) + payload.
fn build_packet(
seq: u32,
timestamp_90k: u32,
frame_index: u32,
flags: u8,
multi_fec_blocks: u8,
fec_info: u32,
payload: &[u8],
) -> Vec<u8> {
let mut p = Vec::with_capacity(SHARD_HEADER + payload.len());
// --- RTP_PACKET (12 bytes, big-endian) ---
p.push(RTP_HEADER_BYTE); // header
p.push(0); // packetType (unused for video)
p.extend_from_slice(&(seq as u16).to_be_bytes()); // sequenceNumber
p.extend_from_slice(&timestamp_90k.to_be_bytes()); // timestamp (90 kHz)
p.extend_from_slice(&0u32.to_be_bytes()); // ssrc
// --- reserved[4] ---
p.extend_from_slice(&[0u8; 4]);
// --- NV_VIDEO_PACKET (16 bytes, little-endian) ---
p.extend_from_slice(&(seq << 8).to_le_bytes()); // streamPacketIndex (low byte 0)
p.extend_from_slice(&frame_index.to_le_bytes()); // frameIndex
p.push(flags);
p.push(0); // extraFlags
p.push(MULTI_FEC_FLAGS);
p.push(multi_fec_blocks);
p.extend_from_slice(&fec_info.to_le_bytes()); // fecInfo
// --- payload ---
p.extend_from_slice(payload);
p
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_block_layout() {
let mut pk = VideoPacketizer::new(1392); // payload_per_shard = 1392+16-32 = 1376
assert_eq!(pk.payload_per_shard, 1376);
let au = vec![0xABu8; 4000]; // 8+4000 = 4008 → ceil(4008/1376) = 3 data shards
let pkts = pk.packetize(&au, FrameType::Idr, 90_000);
assert_eq!(pkts.len(), 3);
// Every datagram is SHARD_HEADER + payload_per_shard.
for p in &pkts {
assert_eq!(p.len(), SHARD_HEADER + 1376);
assert_eq!(p[0], 0x90); // RTP header byte
}
// First packet: SOF set, fecIndex 0, frameIndex 0.
let first = &pkts[0];
assert_eq!(first[24] & FLAG_SOF, FLAG_SOF);
assert_eq!(first[24] & FLAG_PIC, FLAG_PIC);
let frame_index = u32::from_le_bytes(first[20..24].try_into().unwrap());
assert_eq!(frame_index, 0);
let fec_info = u32::from_le_bytes(first[28..32].try_into().unwrap());
assert_eq!(fec_info >> 22, 3); // dataShards = 3
assert_eq!((fec_info >> 12) & 0x3ff, 0); // fecIndex 0
// Last packet: EOF set, fecIndex 2.
let last = &pkts[2];
assert_eq!(last[24] & FLAG_EOF, FLAG_EOF);
let fec_info_last = u32::from_le_bytes(last[28..32].try_into().unwrap());
assert_eq!((fec_info_last >> 12) & 0x3ff, 2);
// RTP sequence numbers are 0,1,2.
for (i, p) in pkts.iter().enumerate() {
assert_eq!(u16::from_be_bytes(p[2..4].try_into().unwrap()), i as u16);
}
}
#[test]
fn multi_block_split() {
let mut pk = VideoPacketizer::new(1392);
// Need > 255 data shards → multi-block. 255*1376 ≈ 351 KB; use 600 KB.
let au = vec![0u8; 600_000];
let pkts = pk.packetize(&au, FrameType::P, 0);
let total = (8 + au.len()).div_ceil(1376);
assert_eq!(pkts.len(), total);
// n_blocks = ceil(total/255), clamped to 4; check multiFecBlocks lastBlock nibble.
let n_blocks = total.div_ceil(255).clamp(1, 4);
let last_block = ((pkts.last().unwrap()[27]) >> 6) & 0x3;
assert_eq!(last_block as usize, n_blocks - 1);
}
}