Files
punktfunk/crates/lumen-host/src/gamestream/control.rs
T
enricobuehler 353d29a00d feat(m2): live video to stock Moonlight — ENet control + video data plane
A stock Moonlight client now decodes H.265 from the lumen host end-to-end
(verified at 5120×1440@120 on RTX 5070 Ti):
- control.rs: ENet control host on UDP 47999 (rusty_enet). Moonlight starts the
  control stream before video (STAGE_CONTROL_STREAM_START precedes _VIDEO_), so it
  must be up first — this was the blocker behind the earlier "error 35".
- stream.rs: video data plane — on RTSP PLAY, learn the client endpoint from its
  ping, NVENC-encode at the negotiated mode, packetize (GameStream RTP/NV/FEC),
  send over UDP 47998; stops when the client disconnects.
- rtsp.rs: ANNOUNCE → StreamConfig (resolution/fps/packetSize/bitrate/codec), PLAY
  starts the stream, TEARDOWN stops it; PairStatus=1 over the mutual-TLS port.

P1.3 uses a synthetic test pattern + data-shards-only FEC (clean-LAN). Next: real
portal desktop capture, input injection (decode control → uinput), nanors-exact FEC,
encryption, audio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:39:14 +00:00

76 lines
3.1 KiB
Rust

//! The GameStream control stream: an ENet host on UDP 47999. Moonlight connects this
//! BEFORE the video stream starts (`STAGE_CONTROL_STREAM_START` precedes
//! `STAGE_VIDEO_STREAM_START`), so it must be up or the whole connection aborts. P1.4 here
//! just accepts the connection + services ENet (keepalive/timeouts) so video can flow;
//! decoding control messages into input injection (mouse/keyboard/gamepad) is the next step.
//!
//! Plaintext for now (we negotiate `encryptionEnabled=0` in DESCRIBE); the encrypted
//! SS_ENC_CONTROL_V2 framing is P1.5. Runs on its own native thread for the host's lifetime.
use super::CONTROL_PORT;
use anyhow::{anyhow, Context, Result};
use rusty_enet::{Event, Host, HostSettings};
use std::net::UdpSocket;
use std::time::Duration;
/// Bind the ENet control host on 47999 and service it forever on a dedicated thread.
pub fn spawn() -> Result<()> {
let socket = UdpSocket::bind(("0.0.0.0", CONTROL_PORT)).context("bind control UDP")?;
socket
.set_nonblocking(true)
.context("control socket nonblocking")?;
let mut host = Host::new(
socket,
HostSettings {
peer_limit: 4,
channel_limit: 8,
..Default::default()
},
)
.map_err(|e| anyhow!("ENet host init: {e:?}"))?;
tracing::info!(port = CONTROL_PORT, "ENet control listening");
std::thread::Builder::new()
.name("lumen-control".into())
.spawn(move || loop {
loop {
match host.service() {
Ok(Some(event)) => match event {
Event::Connect { .. } => {
tracing::info!("control: client connected");
}
Event::Disconnect { .. } => {
tracing::info!("control: client disconnected");
}
Event::Receive {
channel_id, packet, ..
} => {
let d = packet.data();
let opcode = if d.len() >= 2 {
u16::from_le_bytes([d[0], d[1]])
} else {
0
};
// TODO(P1.4): decode input events (mouse/keyboard/gamepad) → inject.rs.
tracing::debug!(
channel_id,
len = d.len(),
opcode = format!("0x{opcode:04x}"),
"control: message"
);
}
},
Ok(None) => break,
Err(e) => {
tracing::warn!(error = %format!("{e:?}"), "control: service error");
break;
}
}
}
// ENet needs frequent servicing for handshake/keepalive/retransmit.
std::thread::sleep(Duration::from_millis(2));
})
.context("spawn control thread")?;
Ok(())
}