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>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
//! 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user