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:
@@ -13,15 +13,30 @@ lumen-core = { path = "../lumen-core" }
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
axum = "0.8"
|
||||
mdns-sd = "0.20"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
rsa = "0.9"
|
||||
sha2 = { version = "0.10", features = ["oid"] }
|
||||
aes = "0.8"
|
||||
rand = "0.8"
|
||||
hex = "0.4"
|
||||
rcgen = { version = "0.13", default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||
x509-parser = "0.16"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rustls = "0.23"
|
||||
rustls-pemfile = "2"
|
||||
|
||||
# Linux backends are wired in M0/M2. They live behind `#[cfg(target_os = "linux")]`
|
||||
# so the workspace stays green on macOS; the dep list (per plan §4) is:
|
||||
#
|
||||
# [target.'cfg(target_os = "linux")'.dependencies]
|
||||
# pipewire = "..." # ScreenCast portal stream -> dmabuf (capture)
|
||||
# ashpd = "..." # xdg-desktop-portal: ScreenCast, RemoteDesktop
|
||||
# zbus = "..." # DBus: KWin/Mutter virtual-output creation
|
||||
# ffmpeg-next / rsmpeg # VAAPI / NVENC encode, dmabuf import
|
||||
# reis = "..." # libei input injection (Wayland-native)
|
||||
# input-linux = "..." # uinput fallback
|
||||
# axum + tokio # web config / pairing API
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# `screencast` gates the ScreenCast portal module; `tokio` is the default runtime.
|
||||
# `open_pipe_wire_remote` is unconditional, so ashpd's own `pipewire` feature is not
|
||||
# needed — we drive PipeWire with the `pipewire` crate below.
|
||||
ashpd = { version = "0.13", features = ["screencast"] }
|
||||
ffmpeg-next = "7"
|
||||
libc = "0.2"
|
||||
# Must match the pipewire crate ashpd 0.13 links (libspa/pipewire-sys `links` key is
|
||||
# unique per build), i.e. 0.9 — NOT the 0.10 the setup doc mentions.
|
||||
pipewire = "0.9"
|
||||
# ashpd 0.13 uses the tokio runtime; a current-thread runtime drives the one-time
|
||||
# portal handshake (control plane — never the per-frame path).
|
||||
tokio = { version = "1", features = ["rt", "rt-multi-thread", "net", "time"] }
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream delivering
|
||||
//! dmabuf frames with no copy to the CPU. The encoder imports the dmabuf directly.
|
||||
//! Frame capture (plan §7). On Linux: a PipeWire ScreenCast portal stream. M0 uses the
|
||||
//! CPU-copy fallback (the portal delivers a CPU buffer; the encoder uploads it to the GPU
|
||||
//! internally). Zero-copy dmabuf→NVENC import is deferred (plan §9 risk).
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
/// A captured frame. For zero-copy the real type wraps a dmabuf fd + modifier; the CPU
|
||||
/// buffer is only a fallback path (plan §9 risk: per-GPU dmabuf import quirks).
|
||||
/// Packed pixel layout of a [`CapturedFrame`]. The ScreenCast portal negotiates the
|
||||
/// format; on wlroots it is commonly packed `RGB` (3 bytes/pixel). The encoder maps these
|
||||
/// to an NVENC-accepted input format (`rgb0`/`bgr0`/`rgba`/`bgra`), expanding 3→4 bytes
|
||||
/// where needed — no host-side colour conversion.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PixelFormat {
|
||||
/// `[B,G,R,x]`, 4 bpp.
|
||||
Bgrx,
|
||||
/// `[R,G,B,x]`, 4 bpp.
|
||||
Rgbx,
|
||||
/// `[B,G,R,A]`, 4 bpp.
|
||||
Bgra,
|
||||
/// `[R,G,B,A]`, 4 bpp.
|
||||
Rgba,
|
||||
/// `[R,G,B]`, 3 bpp.
|
||||
Rgb,
|
||||
/// `[B,G,R]`, 3 bpp.
|
||||
Bgr,
|
||||
}
|
||||
|
||||
impl PixelFormat {
|
||||
pub fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
PixelFormat::Rgb | PixelFormat::Bgr => 3,
|
||||
_ => 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A captured frame. For zero-copy the real type would wrap a dmabuf fd + modifier; the
|
||||
/// CPU buffer is the M0 fallback path (plan §9 risk: per-GPU dmabuf import quirks).
|
||||
pub struct CapturedFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pts_ns: u64,
|
||||
/// Fallback CPU pixels (empty when a dmabuf is used).
|
||||
/// Pixel layout of `cpu_bytes`.
|
||||
pub format: PixelFormat,
|
||||
/// Tightly-packed pixels in `format`, `width * height * format.bytes_per_pixel()`
|
||||
/// bytes (no row padding).
|
||||
pub cpu_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -19,14 +52,86 @@ pub trait Capturer: Send {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||
}
|
||||
|
||||
/// Open a capturer for a PipeWire node id (from the ScreenCast portal).
|
||||
pub fn open_pipewire(_node_id: u32) -> Result<Box<dyn Capturer>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
anyhow::bail!("pipewire capture not yet implemented (M0)")
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
anyhow::bail!("capture requires Linux + PipeWire")
|
||||
/// A deterministic moving test pattern (BGRx). Lets M0 exercise the encode → file →
|
||||
/// `lumen_core` path with no live capture session, and produces obviously non-static
|
||||
/// content (a sweeping bar + animated gradient) so the encoded output is verifiable.
|
||||
pub struct SyntheticCapturer {
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
frame_idx: u64,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SyntheticCapturer {
|
||||
const BPP: usize = 4; // emits BGRx
|
||||
|
||||
pub fn new(width: u32, height: u32, fps: u32) -> Self {
|
||||
assert!(width > 0 && height > 0 && fps > 0);
|
||||
let buf = vec![0u8; width as usize * height as usize * Self::BPP];
|
||||
SyntheticCapturer {
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_idx: 0,
|
||||
buf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Capturer for SyntheticCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
let w = self.width as usize;
|
||||
let h = self.height as usize;
|
||||
let bpp = Self::BPP;
|
||||
let t = self.frame_idx;
|
||||
// A vertical bar sweeps left→right once every ~2s; the background is a gradient
|
||||
// whose phase advances each frame, so every pixel changes frame-to-frame.
|
||||
let bar_x = ((t * w as u64) / (self.fps as u64 * 2)) % w as u64;
|
||||
let phase = (t % 256) as usize;
|
||||
for y in 0..h {
|
||||
let row = y * w * bpp;
|
||||
for x in 0..w {
|
||||
let i = row + x * bpp;
|
||||
let on_bar = (x as u64).abs_diff(bar_x) < 8;
|
||||
// BGRx byte order: [B, G, R, x]
|
||||
self.buf[i] = if on_bar {
|
||||
255
|
||||
} else {
|
||||
((x + phase) & 0xff) as u8
|
||||
};
|
||||
self.buf[i + 1] = if on_bar {
|
||||
255
|
||||
} else {
|
||||
((y + phase) & 0xff) as u8
|
||||
};
|
||||
self.buf[i + 2] = if on_bar { 255 } else { ((x + y) & 0xff) as u8 };
|
||||
self.buf[i + 3] = 0;
|
||||
}
|
||||
}
|
||||
let pts_ns = self.frame_idx * 1_000_000_000 / self.fps as u64;
|
||||
self.frame_idx += 1;
|
||||
Ok(CapturedFrame {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
pts_ns,
|
||||
format: PixelFormat::Bgrx,
|
||||
cpu_bytes: self.buf.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal
|
||||
/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
linux::PortalCapturer::open().map(|c| Box::new(c) as Box<dyn Capturer>)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
|
||||
anyhow::bail!("portal capture requires Linux (xdg-desktop-portal + PipeWire)")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path.
|
||||
//!
|
||||
//! Two dedicated threads, because both stacks are tied to their thread:
|
||||
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
|
||||
//! (control plane — never the per-frame path), then parks on a pending future so the
|
||||
//! `proxy` + its zbus connection stay alive (the cast is torn down when that connection
|
||||
//! drops; ashpd's `Session` has no `Drop`);
|
||||
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
|
||||
//!
|
||||
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx
|
||||
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
|
||||
//! comes from the negotiated PipeWire format, not the portal's size hint.
|
||||
//!
|
||||
//! Cleanup note (M0): the two threads are detached and torn down at process exit. A
|
||||
//! graceful stop (pipewire `channel` quit + Session close) belongs with the M2 session
|
||||
//! lifecycle.
|
||||
|
||||
use super::{CapturedFrame, Capturer, PixelFormat};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Live monitor capturer backed by the portal + PipeWire threads.
|
||||
pub struct PortalCapturer {
|
||||
frames: Receiver<CapturedFrame>,
|
||||
}
|
||||
|
||||
impl PortalCapturer {
|
||||
pub fn open() -> Result<PortalCapturer> {
|
||||
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
thread::Builder::new()
|
||||
.name("lumen-portal".into())
|
||||
.spawn(move || portal_thread(setup_tx))
|
||||
.context("spawn portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => return Err(anyhow!("ScreenCast portal setup failed: {e}")),
|
||||
Err(_) => return Err(anyhow!("timed out waiting for the ScreenCast portal")),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
"ScreenCast portal session started; connecting PipeWire"
|
||||
);
|
||||
|
||||
// Frames flow from the pipewire thread over a small bounded channel.
|
||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
||||
thread::Builder::new()
|
||||
.name("lumen-pipewire".into())
|
||||
.spawn(move || {
|
||||
if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx) {
|
||||
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
||||
}
|
||||
})
|
||||
.context("spawn pipewire thread")?;
|
||||
|
||||
Ok(PortalCapturer { frames: frame_rx })
|
||||
}
|
||||
}
|
||||
|
||||
impl Capturer for PortalCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// First frame can lag behind format negotiation; later frames arrive at ~fps.
|
||||
match self.frames.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(frame) => Ok(frame),
|
||||
Err(RecvTimeoutError::Timeout) => Err(anyhow!("no PipeWire frame within 10s")),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("PipeWire capture thread ended")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The portal handshake: connect ScreenCast, select a single monitor, start, open the
|
||||
/// PipeWire remote, hand the fd + node id back, then keep the session alive.
|
||||
fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus connection's background reader must be pumped
|
||||
// continuously across the create_session → select_sources → start handshake, or the
|
||||
// portal reports "Invalid session". (A current-thread runtime starves it.)
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("connect ScreenCast portal")?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Hidden)
|
||||
// Only MONITOR is offered by the wlroots backend
|
||||
// (AvailableSourceTypes=1); requesting unsupported types
|
||||
// invalidates the session.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected (unsupported source type / cursor mode?)")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser cancelled? portal misconfigured?)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
|
||||
|
||||
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
|
||||
// capture; the cast is torn down when the connection drops (ashpd's `Session`
|
||||
// has no `Drop`), which here happens at process exit.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mod pipewire {
|
||||
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
|
||||
|
||||
use super::{CapturedFrame, PixelFormat};
|
||||
use anyhow::{Context, Result};
|
||||
use pipewire as pw;
|
||||
use pw::{properties::properties, spa};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::sync::mpsc::SyncSender;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use spa::param::video::{VideoFormat, VideoInfoRaw};
|
||||
use spa::pod::Pod;
|
||||
|
||||
/// Map a negotiated SPA video format to a layout the encoder can consume. Returns
|
||||
/// `None` for formats we don't handle (the frame is then skipped).
|
||||
fn map_format(f: VideoFormat) -> Option<PixelFormat> {
|
||||
Some(match f {
|
||||
VideoFormat::BGRx => PixelFormat::Bgrx,
|
||||
VideoFormat::RGBx => PixelFormat::Rgbx,
|
||||
VideoFormat::BGRA => PixelFormat::Bgra,
|
||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||
VideoFormat::RGB => PixelFormat::Rgb,
|
||||
VideoFormat::BGR => PixelFormat::Bgr,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
struct UserData {
|
||||
info: VideoInfoRaw,
|
||||
/// Negotiated layout (`None` until param_changed, or if unsupported).
|
||||
format: Option<PixelFormat>,
|
||||
tx: SyncSender<CapturedFrame>,
|
||||
}
|
||||
|
||||
pub fn pipewire_thread(fd: OwnedFd, node_id: u32, tx: SyncSender<CapturedFrame>) -> Result<()> {
|
||||
pw::init();
|
||||
|
||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
||||
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
|
||||
let core = context
|
||||
.connect_fd_rc(fd, None)
|
||||
.context("pw connect_fd (portal remote)")?;
|
||||
|
||||
let data = UserData {
|
||||
info: VideoInfoRaw::default(),
|
||||
format: None,
|
||||
tx,
|
||||
};
|
||||
|
||||
let stream = pw::stream::StreamBox::new(
|
||||
&core,
|
||||
"lumen-screencast",
|
||||
properties! {
|
||||
*pw::keys::MEDIA_TYPE => "Video",
|
||||
*pw::keys::MEDIA_CATEGORY => "Capture",
|
||||
*pw::keys::MEDIA_ROLE => "Screen",
|
||||
},
|
||||
)
|
||||
.context("pw Stream")?;
|
||||
|
||||
let _listener = stream
|
||||
.add_local_listener_with_user_data(data)
|
||||
.state_changed(|_stream, _ud, old, new| {
|
||||
tracing::info!(?old, ?new, "pipewire stream state");
|
||||
})
|
||||
.param_changed(|_stream, ud, id, param| {
|
||||
let Some(param) = param else { return };
|
||||
if id != pw::spa::param::ParamType::Format.as_raw() {
|
||||
return;
|
||||
}
|
||||
let Ok((media_type, media_subtype)) =
|
||||
pw::spa::param::format_utils::parse_format(param)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if media_type != pw::spa::param::format::MediaType::Video
|
||||
|| media_subtype != pw::spa::param::format::MediaSubtype::Raw
|
||||
{
|
||||
return;
|
||||
}
|
||||
if ud.info.parse(param).is_ok() {
|
||||
let sz = ud.info.size();
|
||||
ud.format = map_format(ud.info.format());
|
||||
tracing::info!(
|
||||
width = sz.width,
|
||||
height = sz.height,
|
||||
spa_format = ?ud.info.format(),
|
||||
mapped = ?ud.format,
|
||||
"pipewire format negotiated"
|
||||
);
|
||||
if ud.format.is_none() {
|
||||
tracing::error!(
|
||||
spa_format = ?ud.info.format(),
|
||||
"negotiated a pixel format the encoder cannot consume — frames will be skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
.process(|stream, ud| {
|
||||
// PipeWire dispatches this from a C trampoline with no catch_unwind; a
|
||||
// panic crossing that FFI boundary would abort the whole host. Contain it.
|
||||
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let Some(mut buffer) = stream.dequeue_buffer() else {
|
||||
return;
|
||||
};
|
||||
let datas = buffer.datas_mut();
|
||||
if datas.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sz = ud.info.size();
|
||||
let (w, h) = (sz.width as usize, sz.height as usize);
|
||||
if w == 0 || h == 0 {
|
||||
return; // format not negotiated yet
|
||||
}
|
||||
let d = &mut datas[0];
|
||||
let (size, offset, stride) = {
|
||||
let c = d.chunk();
|
||||
(
|
||||
c.size() as usize,
|
||||
c.offset() as usize,
|
||||
c.stride().max(0) as usize,
|
||||
)
|
||||
};
|
||||
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
|
||||
let bpp = fmt.bytes_per_pixel();
|
||||
let row = w * bpp;
|
||||
let stride = if stride == 0 { row } else { stride };
|
||||
let Some(buf) = d.data() else { return };
|
||||
// Need stride*(h-1)+row valid bytes within [offset, offset+size).
|
||||
if stride < row || offset > buf.len() {
|
||||
return;
|
||||
}
|
||||
let avail = buf.len() - offset;
|
||||
let needed = stride * (h - 1) + row;
|
||||
if needed > avail || needed > size {
|
||||
return;
|
||||
}
|
||||
let region = &buf[offset..offset + size.min(avail)];
|
||||
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
|
||||
let mut tight = vec![0u8; row * h];
|
||||
for y in 0..h {
|
||||
tight[y * row..y * row + row]
|
||||
.copy_from_slice(®ion[y * stride..y * stride + row]);
|
||||
}
|
||||
let pts_ns = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let frame = CapturedFrame {
|
||||
width: w as u32,
|
||||
height: h as u32,
|
||||
pts_ns,
|
||||
format: fmt,
|
||||
cpu_bytes: tight,
|
||||
};
|
||||
// Drop if the encoder is behind — never block the pipewire loop.
|
||||
let _ = ud.tx.try_send(frame);
|
||||
}));
|
||||
if outcome.is_err() {
|
||||
tracing::error!("panic in pipewire process callback — frame dropped");
|
||||
}
|
||||
})
|
||||
.register()
|
||||
.context("register stream listener")?;
|
||||
|
||||
// Request raw video in any encoder-mappable layout, any size/framerate.
|
||||
let obj = pw::spa::pod::object!(
|
||||
pw::spa::utils::SpaTypes::ObjectParamFormat,
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaType,
|
||||
Id,
|
||||
pw::spa::param::format::MediaType::Video
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::MediaSubtype,
|
||||
Id,
|
||||
pw::spa::param::format::MediaSubtype::Raw
|
||||
),
|
||||
// Offer the layouts the encoder can map to an NVENC input format. wlroots
|
||||
// commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only
|
||||
// these are requested, so negotiation fails loudly rather than handing us a
|
||||
// format we'd misinterpret.
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFormat,
|
||||
Choice,
|
||||
Enum,
|
||||
Id,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::RGB,
|
||||
VideoFormat::BGR,
|
||||
VideoFormat::RGBx,
|
||||
VideoFormat::BGRx,
|
||||
VideoFormat::RGBA,
|
||||
VideoFormat::BGRA,
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoSize,
|
||||
Choice,
|
||||
Range,
|
||||
Rectangle,
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 1,
|
||||
height: 1
|
||||
},
|
||||
pw::spa::utils::Rectangle {
|
||||
width: 8192,
|
||||
height: 8192
|
||||
}
|
||||
),
|
||||
pw::spa::pod::property!(
|
||||
pw::spa::param::format::FormatProperties::VideoFramerate,
|
||||
Choice,
|
||||
Range,
|
||||
Fraction,
|
||||
pw::spa::utils::Fraction { num: 60, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 0, denom: 1 },
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
|
||||
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
||||
std::io::Cursor::new(Vec::new()),
|
||||
&pw::spa::pod::Value::Object(obj),
|
||||
)
|
||||
.context("serialize format pod")?
|
||||
.0
|
||||
.into_inner();
|
||||
let mut params = [Pod::from_bytes(&values).context("pod from bytes")?];
|
||||
|
||||
stream
|
||||
.connect(
|
||||
spa::utils::Direction::Input,
|
||||
Some(node_id),
|
||||
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
||||
&mut params,
|
||||
)
|
||||
.context("pw stream connect")?;
|
||||
|
||||
// Blocks this thread, pumping frame callbacks until process exit.
|
||||
mainloop.run();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
//! Hardware video encode (plan §7). Binds FFmpeg (VAAPI / NVENC); never rewrites codecs.
|
||||
//! Low-latency preset, lookahead off, dmabuf import for zero-copy from [`crate::capture`].
|
||||
//! Hardware video encode (plan §7). Binds FFmpeg (NVENC); never rewrites codecs.
|
||||
//! Low-latency preset, B-frames off. M0 feeds BGRx CPU frames directly — `*_nvenc`
|
||||
//! accepts `bgr0` input and converts to YUV on the GPU, so no host-side swscale is
|
||||
//! needed (dmabuf zero-copy import is deferred; plan §9).
|
||||
|
||||
use crate::capture::CapturedFrame;
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::Result;
|
||||
|
||||
/// An encoded access unit (one NAL/AU) to hand to `lumen_core` for FEC + packetization.
|
||||
/// `data` is in-band Annex-B (the encoder is opened without a global header), so each
|
||||
/// keyframe carries its own VPS/SPS/PPS — the bytes are both a playable elementary
|
||||
/// stream and a self-contained AU for the wire.
|
||||
pub struct EncodedFrame {
|
||||
pub data: Vec<u8>,
|
||||
pub pts_ns: u64,
|
||||
@@ -20,21 +25,50 @@ pub enum Codec {
|
||||
Av1,
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// The FFmpeg NVENC encoder name (selected by name, not codec id — the latter would
|
||||
/// pick the software encoder).
|
||||
pub fn nvenc_name(self) -> &'static str {
|
||||
match self {
|
||||
Codec::H264 => "h264_nvenc",
|
||||
Codec::H265 => "hevc_nvenc",
|
||||
Codec::Av1 => "av1_nvenc",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A hardware encoder. One per session; runs on the encode thread.
|
||||
pub trait Encoder: Send {
|
||||
fn submit(&mut self, frame: &CapturedFrame) -> Result<()>;
|
||||
/// Pull the next encoded AU if one is ready.
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>>;
|
||||
/// Signal end-of-stream. After this, drain the remaining AUs with [`poll`](Self::poll)
|
||||
/// until it returns `None` — NVENC buffers frames internally even at `delay=0`.
|
||||
fn flush(&mut self) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Open an encoder. `bitrate_bps` and `codec` come from session negotiation.
|
||||
pub fn open(_codec: Codec, _bitrate_bps: u64) -> Result<Box<dyn Encoder>> {
|
||||
/// Open an NVENC encoder for packed RGB/BGR CPU frames of the given `format` and mode.
|
||||
/// `format`, `bitrate_bps`, `codec`, and the mode come from session negotiation; M0 takes
|
||||
/// them from the first captured frame.
|
||||
pub fn open_video(
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
) -> Result<Box<dyn Encoder>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
anyhow::bail!("VAAPI/NVENC encode not yet implemented (M0)")
|
||||
let enc = linux::NvencEncoder::open(codec, format, width, height, fps, bitrate_bps)?;
|
||||
Ok(Box::new(enc) as Box<dyn Encoder>)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
anyhow::bail!("encode requires Linux (VAAPI/NVENC via FFmpeg)")
|
||||
let _ = (codec, format, width, height, fps, bitrate_bps);
|
||||
anyhow::bail!("NVENC encode requires Linux (FFmpeg + NVIDIA driver)")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! NVENC encoder via `ffmpeg-next` (binds the system FFmpeg 7.x / libavcodec 61).
|
||||
//!
|
||||
//! Input is a packed RGB/BGR CPU frame; `*_nvenc` accepts `rgb0`/`bgr0`/`rgba`/`bgra`
|
||||
//! directly and does the RGB→YUV conversion on the GPU, so the host stays off the
|
||||
//! colour-conversion path. The portal commonly negotiates packed 24-bit `RGB`, which NVENC
|
||||
//! does *not* accept — we expand it to `rgb0` (one padding byte/pixel, no colour math).
|
||||
//! The encoder is opened *without* a global header so VPS/SPS/PPS are emitted in-band on
|
||||
//! every IDR — the output is both a playable raw Annex-B stream and self-contained AUs.
|
||||
|
||||
use super::{Codec, EncodedFrame, Encoder};
|
||||
use crate::capture::{CapturedFrame, PixelFormat};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use ffmpeg::format::Pixel;
|
||||
use ffmpeg::util::frame::Video as VideoFrame;
|
||||
use ffmpeg::{codec, encoder, Dictionary, Packet, Rational};
|
||||
use ffmpeg_next as ffmpeg;
|
||||
|
||||
/// Map a captured layout to the NVENC input pixel format, and whether a 3→4 byte expand is
|
||||
/// needed (packed RGB/BGR have no padding byte; the NVENC `*0` formats do).
|
||||
fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
|
||||
match format {
|
||||
PixelFormat::Bgrx => (Pixel::BGRZ, false), // bgr0
|
||||
PixelFormat::Rgbx => (Pixel::RGBZ, false), // rgb0
|
||||
PixelFormat::Bgra => (Pixel::BGRA, false),
|
||||
PixelFormat::Rgba => (Pixel::RGBA, false),
|
||||
PixelFormat::Rgb => (Pixel::RGBZ, true), // RGB -> rgb0
|
||||
PixelFormat::Bgr => (Pixel::BGRZ, true), // BGR -> bgr0
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NvencEncoder {
|
||||
enc: encoder::video::Encoder,
|
||||
/// Reusable 4-bpp input frame in `nvenc_pixel` (its plane stride may exceed width*4).
|
||||
/// Mutating it in place across frames is sound only because the encoder is opened with
|
||||
/// `delay=0`/`bf=0`/`max_b_frames=0` and the caller drains `poll()` after each `submit`,
|
||||
/// so libavcodec holds no reference to the previous frame's buffer when we overwrite it.
|
||||
frame: VideoFrame,
|
||||
src_format: PixelFormat,
|
||||
expand: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
/// Monotonic presentation index, in `1/fps` time-base units.
|
||||
frame_idx: i64,
|
||||
}
|
||||
|
||||
impl NvencEncoder {
|
||||
pub fn open(
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
) -> Result<Self> {
|
||||
ffmpeg::init().context("ffmpeg init")?;
|
||||
let name = codec.nvenc_name();
|
||||
let av_codec = encoder::find_by_name(name)
|
||||
.ok_or_else(|| anyhow!("{name} not built into libavcodec"))?;
|
||||
let (nvenc_pixel, expand) = nvenc_input(format);
|
||||
|
||||
let mut video = codec::context::Context::new_with_codec(av_codec)
|
||||
.encoder()
|
||||
.video()
|
||||
.context("alloc video encoder")?;
|
||||
video.set_width(width);
|
||||
video.set_height(height);
|
||||
video.set_format(nvenc_pixel); // NVENC converts RGB→YUV internally
|
||||
video.set_time_base(Rational(1, fps as i32));
|
||||
video.set_frame_rate(Some(Rational(fps as i32, 1)));
|
||||
video.set_bit_rate(bitrate_bps as usize);
|
||||
video.set_max_bit_rate(bitrate_bps as usize);
|
||||
video.set_gop(fps.saturating_mul(2).max(1)); // ~2s keyframe interval
|
||||
video.set_max_b_frames(0);
|
||||
|
||||
// Low-latency NVENC tuning (plan §7 / linux-setup doc).
|
||||
let mut opts = Dictionary::new();
|
||||
opts.set("preset", "p1"); // fastest
|
||||
opts.set("tune", "ull"); // ultra-low-latency
|
||||
opts.set("rc", "cbr");
|
||||
opts.set("bf", "0");
|
||||
opts.set("delay", "0");
|
||||
|
||||
let enc = video
|
||||
.open_with(opts)
|
||||
.with_context(|| format!("open {name} ({width}x{height}@{fps}, {bitrate_bps} bps)"))?;
|
||||
|
||||
let frame = VideoFrame::new(nvenc_pixel, width, height);
|
||||
Ok(NvencEncoder {
|
||||
enc,
|
||||
frame,
|
||||
src_format: format,
|
||||
expand,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
frame_idx: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoder for NvencEncoder {
|
||||
fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
|
||||
anyhow::ensure!(
|
||||
captured.width == self.width && captured.height == self.height,
|
||||
"captured frame {}x{} != encoder {}x{}",
|
||||
captured.width,
|
||||
captured.height,
|
||||
self.width,
|
||||
self.height
|
||||
);
|
||||
anyhow::ensure!(
|
||||
captured.format == self.src_format,
|
||||
"captured format {:?} != encoder source {:?}",
|
||||
captured.format,
|
||||
self.src_format
|
||||
);
|
||||
let w = self.width as usize;
|
||||
let h = self.height as usize;
|
||||
let src_bpp = self.src_format.bytes_per_pixel();
|
||||
let src_row = w * src_bpp;
|
||||
anyhow::ensure!(
|
||||
captured.cpu_bytes.len() >= src_row * h,
|
||||
"captured buffer {} bytes < required {}",
|
||||
captured.cpu_bytes.len(),
|
||||
src_row * h
|
||||
);
|
||||
|
||||
let stride = self.frame.stride(0); // dst is 4-bpp, aligned
|
||||
let dst = self.frame.data_mut(0);
|
||||
if self.expand {
|
||||
// packed 3-bpp RGB/BGR → 4-bpp *0 (copy 3 bytes, zero the pad byte)
|
||||
for y in 0..h {
|
||||
let s = &captured.cpu_bytes[y * src_row..y * src_row + src_row];
|
||||
let drow = &mut dst[y * stride..y * stride + w * 4];
|
||||
for x in 0..w {
|
||||
drow[x * 4..x * 4 + 3].copy_from_slice(&s[x * 3..x * 3 + 3]);
|
||||
drow[x * 4 + 3] = 0;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 4-bpp → 4-bpp, honoring the (possibly larger) dst stride
|
||||
for y in 0..h {
|
||||
dst[y * stride..y * stride + src_row]
|
||||
.copy_from_slice(&captured.cpu_bytes[y * src_row..y * src_row + src_row]);
|
||||
}
|
||||
}
|
||||
self.frame.set_pts(Some(self.frame_idx));
|
||||
self.frame_idx += 1;
|
||||
self.enc.send_frame(&self.frame).context("send_frame")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
|
||||
let mut pkt = Packet::empty();
|
||||
match self.enc.receive_packet(&mut pkt) {
|
||||
Ok(()) => {
|
||||
let data = pkt.data().map(|d| d.to_vec()).unwrap_or_default();
|
||||
let pts = pkt.pts().unwrap_or(0).max(0) as u64;
|
||||
let pts_ns = pts * 1_000_000_000 / self.fps as u64;
|
||||
Ok(Some(EncodedFrame {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe: pkt.is_key(),
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
if errno == ffmpeg::util::error::EAGAIN
|
||||
|| errno == ffmpeg::util::error::EWOULDBLOCK =>
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
// Fully drained after flush().
|
||||
Err(ffmpeg::Error::Eof) => Ok(None),
|
||||
Err(e) => Err(e).context("receive_packet"),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> Result<()> {
|
||||
self.enc.send_eof().context("send_eof")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 Reed–Solomon 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(×tamp_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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! M0 — the pipeline spike (plan §8): capture → NVENC encode → playable file, with the
|
||||
//! encoded access units also fed through a `lumen_core` host→client `Session` over an
|
||||
//! in-process loopback to prove the core's FEC + packetize + reassemble path on real
|
||||
//! encoder output.
|
||||
//!
|
||||
//! This is the spike runner, not the M2 hot path: it drives the stages on one thread (the
|
||||
//! per-stage-thread pipeline with bounded channels is [`crate::pipeline`]). Source is
|
||||
//! either a synthetic BGRx test pattern (no capture session needed) or the live xdg
|
||||
//! ScreenCast portal monitor.
|
||||
|
||||
use crate::capture::{self, Capturer, SyntheticCapturer};
|
||||
use crate::encode::{self, Codec, EncodedFrame, Encoder};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use lumen_core::packet::{FLAG_PIC, FLAG_SOF};
|
||||
use lumen_core::{Config, Role, Session};
|
||||
use std::fs::File;
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// Deterministic moving BGRx test pattern — no capture session required.
|
||||
Synthetic,
|
||||
/// Live monitor via the xdg ScreenCast portal + PipeWire.
|
||||
Portal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Options {
|
||||
pub source: Source,
|
||||
/// Synthetic-only; the portal source uses the PipeWire-negotiated size.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub fps: u32,
|
||||
pub seconds: u32,
|
||||
pub codec: Codec,
|
||||
pub bitrate_bps: u64,
|
||||
/// Raw Annex-B elementary-stream sink (`.h265`/`.h264`/`.ivf-less .obu`); playable.
|
||||
pub out: PathBuf,
|
||||
/// Also round-trip every AU through a `lumen_core` host→client loopback and verify.
|
||||
pub loopback: bool,
|
||||
}
|
||||
|
||||
pub fn run(opts: Options) -> Result<()> {
|
||||
let mut capturer: Box<dyn Capturer> = match opts.source {
|
||||
Source::Synthetic => {
|
||||
tracing::info!(
|
||||
width = opts.width,
|
||||
height = opts.height,
|
||||
fps = opts.fps,
|
||||
"M0 source: synthetic BGRx test pattern"
|
||||
);
|
||||
Box::new(SyntheticCapturer::new(opts.width, opts.height, opts.fps))
|
||||
}
|
||||
Source::Portal => {
|
||||
tracing::info!("M0 source: xdg ScreenCast portal (live monitor)");
|
||||
capture::open_portal_monitor().context("open portal capturer")?
|
||||
}
|
||||
};
|
||||
|
||||
// The first frame establishes the authoritative dimensions (the portal's negotiated
|
||||
// size, or the synthetic size) used to configure the encoder.
|
||||
let first = capturer.next_frame().context("capture first frame")?;
|
||||
let (w, h) = (first.width, first.height);
|
||||
tracing::info!(
|
||||
width = w,
|
||||
height = h,
|
||||
format = ?first.format,
|
||||
codec = ?opts.codec,
|
||||
bitrate_bps = opts.bitrate_bps,
|
||||
"opening NVENC encoder"
|
||||
);
|
||||
let mut encoder =
|
||||
encode::open_video(opts.codec, first.format, w, h, opts.fps, opts.bitrate_bps)
|
||||
.context("open encoder")?;
|
||||
|
||||
let mut sink = BufWriter::new(
|
||||
File::create(&opts.out).with_context(|| format!("create {}", opts.out.display()))?,
|
||||
);
|
||||
|
||||
let mut lb = if opts.loopback {
|
||||
Some(Loopback::new().context("build lumen-core loopback")?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let target_frames = (opts.seconds as u64) * (opts.fps as u64);
|
||||
let started = Instant::now();
|
||||
let mut stats = Stats::default();
|
||||
|
||||
let mut frame = first;
|
||||
loop {
|
||||
encoder.submit(&frame).context("encoder submit")?;
|
||||
stats.submitted += 1;
|
||||
drain_encoder(encoder.as_mut(), &mut sink, lb.as_mut(), &mut stats)?;
|
||||
if stats.submitted >= target_frames {
|
||||
break;
|
||||
}
|
||||
frame = capturer.next_frame().context("capture frame")?;
|
||||
}
|
||||
|
||||
// NVENC buffers frames internally even at delay=0 — flush and drain the tail.
|
||||
encoder.flush().context("encoder flush")?;
|
||||
drain_encoder(encoder.as_mut(), &mut sink, lb.as_mut(), &mut stats)?;
|
||||
sink.flush().context("flush output file")?;
|
||||
|
||||
let elapsed = started.elapsed().as_secs_f64();
|
||||
tracing::info!(
|
||||
submitted = stats.submitted,
|
||||
encoded = stats.encoded,
|
||||
keyframes = stats.keyframes,
|
||||
bytes_out = stats.bytes_out,
|
||||
out = %opts.out.display(),
|
||||
elapsed_s = format!("{elapsed:.2}"),
|
||||
encode_fps = format!("{:.1}", stats.encoded as f64 / elapsed.max(1e-9)),
|
||||
"M0 capture→encode→file complete"
|
||||
);
|
||||
|
||||
if let Some(lb) = lb {
|
||||
lb.report();
|
||||
if lb.mismatches > 0 || lb.recovered != lb.submitted {
|
||||
return Err(anyhow!(
|
||||
"lumen-core loopback verification FAILED: {} mismatches, {}/{} AUs recovered",
|
||||
lb.mismatches,
|
||||
lb.recovered,
|
||||
lb.submitted
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Stats {
|
||||
submitted: u64,
|
||||
encoded: u64,
|
||||
keyframes: u64,
|
||||
bytes_out: u64,
|
||||
}
|
||||
|
||||
fn drain_encoder(
|
||||
encoder: &mut dyn Encoder,
|
||||
sink: &mut impl Write,
|
||||
mut lb: Option<&mut Loopback>,
|
||||
stats: &mut Stats,
|
||||
) -> Result<()> {
|
||||
while let Some(au) = encoder.poll().context("encoder poll")? {
|
||||
sink.write_all(&au.data).context("write AU to file")?;
|
||||
stats.encoded += 1;
|
||||
stats.bytes_out += au.data.len() as u64;
|
||||
if au.keyframe {
|
||||
stats.keyframes += 1;
|
||||
}
|
||||
if let Some(lb) = lb.as_deref_mut() {
|
||||
lb.submit(&au)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A host↔client `lumen_core` pair over a lossless in-process loopback. Each encoded AU is
|
||||
/// FEC-protected, packetized, sent, then reassembled on the client and byte-compared to the
|
||||
/// original — exercising the core on real encoder output (the M0 "feed into a Session" goal).
|
||||
struct Loopback {
|
||||
host: Session,
|
||||
client: Session,
|
||||
submitted: u64,
|
||||
recovered: u64,
|
||||
mismatches: u64,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Loopback {
|
||||
fn new() -> Result<Loopback> {
|
||||
let (host_tx, client_tx) = lumen_core::transport::loopback_pair(0, 0);
|
||||
let host = Session::new(Config::p1_defaults(Role::Host), Box::new(host_tx))
|
||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||
let client = Session::new(Config::p1_defaults(Role::Client), Box::new(client_tx))
|
||||
.map_err(|e| anyhow!("client session: {e:?}"))?;
|
||||
Ok(Loopback {
|
||||
host,
|
||||
client,
|
||||
submitted: 0,
|
||||
recovered: 0,
|
||||
mismatches: 0,
|
||||
bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn submit(&mut self, au: &EncodedFrame) -> Result<()> {
|
||||
let mut flags = FLAG_PIC as u32;
|
||||
if au.keyframe {
|
||||
flags |= FLAG_SOF as u32;
|
||||
}
|
||||
self.host
|
||||
.submit_frame(&au.data, au.pts_ns, flags)
|
||||
.map_err(|e| anyhow!("host submit_frame: {e:?}"))?;
|
||||
self.submitted += 1;
|
||||
self.bytes += au.data.len() as u64;
|
||||
|
||||
// Lossless + in-order loopback: each submit yields exactly the AU just sent.
|
||||
loop {
|
||||
match self.client.poll_frame() {
|
||||
Ok(frame) => {
|
||||
self.recovered += 1;
|
||||
if frame.data != au.data {
|
||||
self.mismatches += 1;
|
||||
tracing::warn!(
|
||||
recovered = self.recovered,
|
||||
got = frame.data.len(),
|
||||
expected = au.data.len(),
|
||||
"loopback AU mismatch"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(lumen_core::LumenError::NoFrame) => break,
|
||||
Err(e) => return Err(anyhow!("client poll_frame: {e:?}")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn report(&self) {
|
||||
tracing::info!(
|
||||
submitted = self.submitted,
|
||||
recovered = self.recovered,
|
||||
mismatches = self.mismatches,
|
||||
bytes = self.bytes,
|
||||
"lumen-core loopback: AUs FEC-packetized → sent → reassembled & verified"
|
||||
);
|
||||
}
|
||||
}
|
||||
+151
-32
@@ -6,20 +6,26 @@
|
||||
//! `#[cfg(target_os = "linux")]`; the crate compiles everywhere so the workspace builds
|
||||
//! on non-Linux dev machines — it just can't run the pipeline there.
|
||||
//!
|
||||
//! Status: scaffold. M0 wires capture→encode→file; M2 wires the full P1 host that a
|
||||
//! stock Moonlight client connects to.
|
||||
//! Status: M0. The `m0` subcommand runs the capture→encode→file pipeline spike and feeds
|
||||
//! the encoded AUs through a `lumen_core` loopback. M2 wires the full P1 host that a stock
|
||||
//! Moonlight client connects to.
|
||||
|
||||
// Scaffold: trait methods and config paths are defined ahead of their backends.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod capture;
|
||||
mod encode;
|
||||
mod gamestream;
|
||||
mod inject;
|
||||
mod m0;
|
||||
mod pipeline;
|
||||
mod vdisplay;
|
||||
mod web;
|
||||
|
||||
use vdisplay::{Compositor, Mode};
|
||||
use anyhow::{bail, Result};
|
||||
use encode::Codec;
|
||||
use m0::{Options, Source};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -28,34 +34,147 @@ fn main() {
|
||||
)
|
||||
.init();
|
||||
|
||||
tracing::info!(
|
||||
"lumen-host scaffold (lumen_core ABI v{})",
|
||||
lumen_core::ABI_VERSION
|
||||
);
|
||||
|
||||
// The intended startup sequence (each step is a separate, pluggable subsystem):
|
||||
// 1. negotiate mode + codec + FEC scheme over the control plane (web::WebConfig)
|
||||
// 2. vdisplay::open(compositor).create(mode) -> client-sized virtual output
|
||||
// 3. capture::open_pipewire(node) ; encode::open(codec, bitrate)
|
||||
// 4. build a lumen_core::Session (host role) over a UDP transport to the client
|
||||
// 5. loop pipeline::pump_once(..) until disconnect, then destroy the output
|
||||
let target_mode = Mode {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
refresh_hz: 240,
|
||||
};
|
||||
let compositor = Compositor::Kwin; // MVP target
|
||||
|
||||
if cfg!(target_os = "linux") {
|
||||
tracing::info!(
|
||||
?compositor,
|
||||
?target_mode,
|
||||
"would create a virtual output and start streaming (backends pending M0/M2)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"this is a Linux host; on {} only the shared lumen_core builds and is testable",
|
||||
std::env::consts::OS
|
||||
);
|
||||
if let Err(e) = real_main() {
|
||||
tracing::error!("{e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn real_main() -> Result<()> {
|
||||
tracing::info!("lumen-host (lumen_core ABI v{})", lumen_core::ABI_VERSION);
|
||||
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(String::as_str) {
|
||||
// M2 GameStream host control plane (P1.1: mDNS + serverinfo).
|
||||
Some("serve") => gamestream::serve(),
|
||||
// M0 pipeline spike.
|
||||
Some("m0") => m0::run(parse_m0(&args[1..])?),
|
||||
Some("-h") | Some("--help") | Some("help") | None => {
|
||||
print_usage();
|
||||
Ok(())
|
||||
}
|
||||
// Bare flags (no subcommand) default to the m0 spike for back-compat.
|
||||
Some(_) => m0::run(parse_m0(&args)?),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_m0(args: &[String]) -> Result<Options> {
|
||||
let mut source = Source::Portal;
|
||||
let mut width = 1920u32;
|
||||
let mut height = 1080u32;
|
||||
let mut fps = 60u32;
|
||||
let mut seconds = 5u32;
|
||||
let mut codec = Codec::H265;
|
||||
let mut bitrate_mbps = 20u64;
|
||||
let mut out: Option<PathBuf> = None;
|
||||
let mut loopback = true;
|
||||
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = args[i].as_str();
|
||||
let mut next = || {
|
||||
i += 1;
|
||||
args.get(i)
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("missing value for {arg}"))
|
||||
};
|
||||
match arg {
|
||||
"--source" => {
|
||||
source = match next()?.as_str() {
|
||||
"synthetic" => Source::Synthetic,
|
||||
"portal" => Source::Portal,
|
||||
other => bail!("unknown --source '{other}' (synthetic|portal)"),
|
||||
}
|
||||
}
|
||||
"--width" => {
|
||||
width = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --width"))?
|
||||
}
|
||||
"--height" => {
|
||||
height = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --height"))?
|
||||
}
|
||||
"--fps" => fps = next()?.parse().map_err(|_| anyhow::anyhow!("bad --fps"))?,
|
||||
"--seconds" => {
|
||||
seconds = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --seconds"))?
|
||||
}
|
||||
"--codec" => {
|
||||
codec = match next()?.as_str() {
|
||||
"h264" => Codec::H264,
|
||||
"h265" | "hevc" => Codec::H265,
|
||||
"av1" => Codec::Av1,
|
||||
other => bail!("unknown --codec '{other}' (h264|h265|av1)"),
|
||||
}
|
||||
}
|
||||
"--bitrate" => {
|
||||
bitrate_mbps = next()?
|
||||
.parse()
|
||||
.map_err(|_| anyhow::anyhow!("bad --bitrate (Mbps)"))?
|
||||
}
|
||||
"--out" => out = Some(PathBuf::from(next()?)),
|
||||
"--no-loopback" => loopback = false,
|
||||
"-h" | "--help" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => bail!("unknown argument '{other}' (try --help)"),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if fps == 0 || width == 0 || height == 0 || seconds == 0 {
|
||||
bail!("--fps/--width/--height/--seconds must be > 0");
|
||||
}
|
||||
|
||||
let out = out.unwrap_or_else(|| {
|
||||
let ext = match codec {
|
||||
Codec::H264 => "h264",
|
||||
Codec::H265 => "h265",
|
||||
Codec::Av1 => "obu",
|
||||
};
|
||||
PathBuf::from(format!("/tmp/lumen-m0.{ext}"))
|
||||
});
|
||||
|
||||
Ok(Options {
|
||||
source,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
seconds,
|
||||
codec,
|
||||
bitrate_bps: bitrate_mbps.saturating_mul(1_000_000),
|
||||
out,
|
||||
loopback,
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"lumen-host — Linux streaming host
|
||||
|
||||
USAGE:
|
||||
lumen-host serve GameStream host control plane (M2: mDNS + serverinfo …)
|
||||
lumen-host m0 [OPTIONS] M0 capture→encode→file pipeline spike
|
||||
|
||||
OPTIONS:
|
||||
--source <synthetic|portal> frame source (default: portal)
|
||||
--seconds <N> capture duration in seconds (default: 5)
|
||||
--fps <N> target frame rate (default: 60)
|
||||
--codec <h264|h265|av1> NVENC codec (default: h265)
|
||||
--bitrate <MBPS> target bitrate in Mbps (default: 20)
|
||||
--width <W> --height <H> synthetic source size (default: 1920x1080)
|
||||
--out <PATH> raw Annex-B output (default: /tmp/lumen-m0.<ext>)
|
||||
--no-loopback skip the lumen_core round-trip verification
|
||||
-h, --help this help
|
||||
|
||||
NOTES:
|
||||
'portal' needs headless Sway + xdg-desktop-portal-wlr running in this session
|
||||
(see docs/linux-setup.md). 'synthetic' needs no capture session and always runs.
|
||||
Encoded AUs are written to a playable file AND (unless --no-loopback) fed through a
|
||||
lumen_core host→client loopback that reassembles and byte-verifies each one."
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user