feat(gamestream): advertise HDR + surface the game library (with covers) to Moonlight
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
apple / swift (push) Successful in 1m6s
ci / rust (push) Failing after 1m11s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 1m2s
android / android (push) Successful in 4m52s
apple / screenshots (push) Successful in 5m20s
windows-host / package (push) Successful in 6m30s
ci / bench (push) Successful in 4m42s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m32s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 18s
Bring the GameStream/Moonlight plane up to the native plane's capability parity. HDR (Windows only): - New host_hdr_capable() gate (Windows + PUNKTFUNK_10BIT, matching the native policy). serverinfo layers SCM_HEVC_MAIN10 onto the probed/static codec mask, so Moonlight finally offers its HDR toggle (live: mask 0x10101 -> 0x10301). - Parse the client's dynamicRangeMode into StreamConfig.hdr and pass it through to OutputFormat::resolve, so a client HDR request proactively enables advanced color on the per-session virtual display (PQ flows even from an SDR desktop). The encoder bit depth now derives from the captured frame format (gs_bit_depth) rather than a hard-coded 8 that mislabeled the already-Main10 HDR stream. Game library in /applist: - The catalog now layers library::all_games() (Steam/Epic/GOG/Xbox/custom) on top of Desktop/apps.json, each with a STABLE GameStream id (FNV-1a, dedup-probed) and the store-qualified library id. Launch routes through the existing security-reviewed launch_title/launch_command via library::launch_gamestream_library — a client can only pick an existing title, never inject a command. - /appasset cover proxy: Moonlight fetches per-app covers from the host, so resolve appid -> library cover URL and proxy the bytes (portrait -> header -> hero -> logo; data: + bounded http(s) fetch), on a blocking thread. IsHdrSupported reflects the host HDR capability. 4:4:4 stays off on GameStream by design: stock Moonlight is 4:2:0 and the Windows IDD-push capturer can't deliver full chroma yet (capturer_supports_444() == false); the gate is documented so it lights up once IDD-push full-chroma capture lands. Validated live (Moonlight -> Windows NVENC host): HDR advertised, the Epic library shows with covers, launch works. clippy clean; apps/serverinfo/library unit tests cover the HDR mask, stable-id, dedup, and data-URL paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ pub struct AppEntry {
|
||||
pub compositor: Option<crate::vdisplay::Compositor>,
|
||||
/// Command gamescope runs nested (gamescope entries only).
|
||||
pub cmd: Option<String>,
|
||||
/// Store-qualified library id (`steam:570`, `epic:…`) for entries surfaced from the host's game
|
||||
/// library ([`crate::library`]). When set, the launch path resolves + launches it against the
|
||||
/// host's own library instead of running [`cmd`](Self::cmd). `None` for Desktop / apps.json entries.
|
||||
pub library_id: Option<String>,
|
||||
}
|
||||
|
||||
fn config_path() -> Option<std::path::PathBuf> {
|
||||
@@ -35,9 +39,18 @@ fn parse_compositor(s: &str) -> Option<crate::vdisplay::Compositor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||
/// entries when gamescope is installed).
|
||||
/// The GameStream catalog Moonlight sees in `/applist`: the operator base ([`base_catalog`] — Desktop +
|
||||
/// apps.json) with the host's auto-detected game library ([`append_library`]) layered on top, so a
|
||||
/// Moonlight client sees the same Steam/Epic/GOG/Xbox titles the native clients do instead of just Desktop.
|
||||
pub fn catalog() -> Vec<AppEntry> {
|
||||
let mut apps = base_catalog();
|
||||
append_library(&mut apps);
|
||||
apps
|
||||
}
|
||||
|
||||
/// The operator base: the user's `apps.json` if present, else defaults (Desktop, plus gamescope
|
||||
/// entries when gamescope is installed). The installed game library is layered on by [`append_library`].
|
||||
fn base_catalog() -> Vec<AppEntry> {
|
||||
if let Some(path) = config_path() {
|
||||
if let Ok(raw) = std::fs::read_to_string(&path) {
|
||||
match serde_json::from_str::<Value>(&raw) {
|
||||
@@ -53,6 +66,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
.and_then(|c| c.as_str())
|
||||
.and_then(parse_compositor),
|
||||
cmd: it.get("cmd").and_then(|c| c.as_str()).map(String::from),
|
||||
library_id: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -72,6 +86,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Desktop".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
}];
|
||||
if which("gamescope") {
|
||||
if which("steam") {
|
||||
@@ -80,6 +95,7 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "Steam".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("steam -gamepadui".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
if which("vkcube") {
|
||||
@@ -88,23 +104,79 @@ pub fn catalog() -> Vec<AppEntry> {
|
||||
title: "vkcube (test)".into(),
|
||||
compositor: Some(crate::vdisplay::Compositor::Gamescope),
|
||||
cmd: Some("vkcube".into()),
|
||||
library_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
apps
|
||||
}
|
||||
|
||||
/// The high half of the positive `i32` range — where library-derived GameStream ids live, kept clear of
|
||||
/// the small Desktop/apps.json ids so the two never collide.
|
||||
const LIBRARY_ID_BASE: u32 = 0x4000_0000;
|
||||
|
||||
/// Append the host's installed game library ([`crate::library::all_games`] — Steam/Epic/GOG/Xbox/custom)
|
||||
/// to `apps`. Each title gets a STABLE GameStream `<ID>` derived from its store-qualified library id
|
||||
/// (Moonlight caches appids, so a title keeps its id across host restarts), carries that library id so
|
||||
/// the launch path resolves it against the host's own library, and is de-duplicated (by id) against the
|
||||
/// base catalog and the other library entries. Titles with no launch recipe are skipped (un-startable).
|
||||
fn append_library(apps: &mut Vec<AppEntry>) {
|
||||
let mut used: std::collections::HashSet<u32> = apps.iter().map(|a| a.id).collect();
|
||||
for g in crate::library::all_games() {
|
||||
if g.launch.is_none() {
|
||||
continue;
|
||||
}
|
||||
let mut id = stable_app_id(&g.id);
|
||||
// Linear-probe within the library range on the (rare) hash collision — deterministic given the
|
||||
// stable all_games() order, so a title keeps its id run to run.
|
||||
while !used.insert(id) {
|
||||
id = LIBRARY_ID_BASE | (id.wrapping_add(1) & 0x3FFF_FFFF);
|
||||
}
|
||||
apps.push(AppEntry {
|
||||
id,
|
||||
title: g.title,
|
||||
compositor: None, // auto-detect the desktop session (Windows ignores the compositor)
|
||||
cmd: None,
|
||||
library_id: Some(g.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A STABLE GameStream `<ID>` for a store-qualified library id (`steam:570`): FNV-1a-32 folded into the
|
||||
/// high half of the positive `i32` range ([`LIBRARY_ID_BASE`]). Deterministic across runs and clear of
|
||||
/// the reserved small Desktop/apps.json ids.
|
||||
fn stable_app_id(library_id: &str) -> u32 {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in library_id.bytes() {
|
||||
h ^= b as u32;
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
LIBRARY_ID_BASE | (h & 0x3FFF_FFFF)
|
||||
}
|
||||
|
||||
pub fn by_id(id: u32) -> Option<AppEntry> {
|
||||
catalog().into_iter().find(|a| a.id == id)
|
||||
}
|
||||
|
||||
/// Render the GameStream `/applist` XML.
|
||||
/// Box-art bytes for the GameStream `/appasset` cover proxy: resolve the Moonlight appid to its catalog
|
||||
/// entry, then (for a library title) fetch its cover from the host's library. `(bytes, content-type)`,
|
||||
/// or `None` for Desktop / apps.json entries (no art) or a fetch failure. Blocking (disk + network) —
|
||||
/// call off the async runtime.
|
||||
pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
|
||||
let lib_id = by_id(appid)?.library_id?;
|
||||
crate::library::fetch_box_art(&lib_id)
|
||||
}
|
||||
|
||||
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
||||
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
||||
/// true, Moonlight offers its per-app HDR toggle.
|
||||
pub fn applist_xml() -> String {
|
||||
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||
let mut xml =
|
||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
||||
for app in catalog() {
|
||||
xml.push_str(&format!(
|
||||
"<App>\n<IsHdrSupported>0</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
||||
xml_escape(&app.title),
|
||||
app.id
|
||||
));
|
||||
@@ -130,10 +202,46 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn default_catalog_has_desktop() {
|
||||
// catalog() = base (Desktop + apps.json) + the installed library; Desktop (id 1) is always present.
|
||||
let apps = catalog();
|
||||
assert!(apps.iter().any(|a| a.id == 1 && a.title == "Desktop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_app_id_is_deterministic_and_in_library_range() {
|
||||
// Same id every run (Moonlight caches appids), distinct per title, and always in the high
|
||||
// half of the positive i32 range so it never collides with the small Desktop/apps.json ids.
|
||||
let a = stable_app_id("steam:570");
|
||||
let b = stable_app_id("steam:570");
|
||||
let c = stable_app_id("steam:271590");
|
||||
assert_eq!(a, b);
|
||||
assert_ne!(a, c);
|
||||
for id in [a, c] {
|
||||
assert!(id >= LIBRARY_ID_BASE, "id {id:#x} below library base");
|
||||
assert!(id <= 0x7FFF_FFFF, "id {id:#x} not a positive i32");
|
||||
assert_ne!(id, 1, "must not collide with Desktop");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_library_dedups_against_base_ids() {
|
||||
// A base app whose id happens to fall in the library range must not be clobbered by a library
|
||||
// entry that hashes to it — append_library probes past any used id.
|
||||
let mut apps = vec![AppEntry {
|
||||
id: stable_app_id("steam:570"),
|
||||
title: "Pinned".into(),
|
||||
compositor: None,
|
||||
cmd: None,
|
||||
library_id: None,
|
||||
}];
|
||||
append_library(&mut apps);
|
||||
let ids: Vec<u32> = apps.iter().map(|a| a.id).collect();
|
||||
let mut uniq = ids.clone();
|
||||
uniq.sort_unstable();
|
||||
uniq.dedup();
|
||||
assert_eq!(ids.len(), uniq.len(), "duplicate GameStream ids in catalog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applist_xml_is_wellformed_ish() {
|
||||
let xml = applist_xml();
|
||||
|
||||
@@ -48,13 +48,26 @@ pub const SCM_HEVC: u32 = 0x0000_0100;
|
||||
pub const SCM_HEVC_MAIN10: u32 = 0x0000_0200;
|
||||
pub const SCM_AV1_MAIN8: u32 = 0x0001_0000;
|
||||
pub const SCM_AV1_MAIN10: u32 = 0x0002_0000;
|
||||
/// What we actually encode via NVENC: H.264, HEVC Main, AV1 Main 8-bit (= 65793). The
|
||||
/// 10-bit flags are deliberately NOT advertised: Moonlight only selects Main10 profiles for
|
||||
/// HDR streaming, and our capture path is 8-bit SDR BGRx with no HDR metadata plumbing —
|
||||
/// advertising them would let clients enable an HDR mode we can't deliver. (The previous
|
||||
/// placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 + 4:4:4 and *no* AV1.)
|
||||
/// The **SDR baseline** codec mask: H.264, HEVC Main, AV1 Main 8-bit (= 65793). HEVC Main10 (HDR) is
|
||||
/// layered on top of this at runtime by `serverinfo::codec_mode_support` when — and only when — the
|
||||
/// host can actually deliver it ([`host_hdr_capable`]); it is never a static claim, because a non-HDR
|
||||
/// host (Linux, or a Windows host without the `PUNKTFUNK_10BIT` opt-in) must not invite a client into
|
||||
/// an HDR mode it can't produce. (The previous placeholder 3843 = 0xF03 wrongly claimed HEVC Main10 +
|
||||
/// 4:4:4 and *no* AV1.) 4:4:4 stays off entirely: stock Moonlight is 4:2:0 and the Windows IDD-push
|
||||
/// capturer can't yet deliver full-chroma frames (`crate::capture::capturer_supports_444`).
|
||||
pub const SERVER_CODEC_MODE_SUPPORT: u32 = SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8;
|
||||
|
||||
/// Whether this host can deliver an **HDR** (HEVC Main10 / BT.2020 PQ) GameStream — the single gate
|
||||
/// for advertising [`SCM_HEVC_MAIN10`] in serverinfo and `IsHdrSupported` per app, and for honoring a
|
||||
/// client's `dynamicRangeMode` request. HDR capture+encode is **Windows-only** (the Linux host is
|
||||
/// 8-bit, blocked upstream) and behind the operator's `PUNKTFUNK_10BIT` opt-in — the same policy gate
|
||||
/// the native punktfunk/1 plane honors. When this is true the IDD-push capturer streams HEVC Main10 PQ
|
||||
/// whenever the desktop is HDR, and a client HDR request makes the GameStream video path proactively
|
||||
/// enable advanced color on the per-session virtual display so PQ flows even from an SDR desktop.
|
||||
pub fn host_hdr_capable() -> bool {
|
||||
cfg!(target_os = "windows") && crate::config::config().ten_bit
|
||||
}
|
||||
|
||||
/// Stable host identity + advertised capabilities, shared across control-plane handlers.
|
||||
pub struct Host {
|
||||
pub hostname: String,
|
||||
|
||||
@@ -13,8 +13,8 @@ use super::{serverinfo, AppState, LaunchSession, HTTPS_PORT, HTTP_PORT, RTSP_POR
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::header,
|
||||
response::IntoResponse,
|
||||
http::{header, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Extension, Router,
|
||||
};
|
||||
@@ -64,6 +64,7 @@ fn router(state: Arc<AppState>, https: bool) -> Router {
|
||||
.route("/serverinfo", get(h_serverinfo))
|
||||
.route("/pair", get(h_pair))
|
||||
.route("/applist", get(h_applist))
|
||||
.route("/appasset", get(h_appasset))
|
||||
.route("/launch", get(h_launch))
|
||||
.route("/resume", get(h_resume))
|
||||
.route("/cancel", get(h_cancel))
|
||||
@@ -94,10 +95,32 @@ async fn h_applist(
|
||||
tracing::warn!("applist rejected — client is not paired");
|
||||
return xml(error_xml());
|
||||
}
|
||||
// One app for now: the headless desktop (the wlroots virtual output).
|
||||
xml(super::apps::applist_xml())
|
||||
}
|
||||
|
||||
/// Box-art cover proxy (`/appasset?appid=N&AssetType=2&AssetIdx=0`). Moonlight fetches per-app covers
|
||||
/// from the HOST, so we resolve the appid to its library title and proxy the cover image bytes (Steam/
|
||||
/// Epic CDN, etc.). 404 for Desktop / apps.json entries (no art) or any fetch failure — Moonlight then
|
||||
/// shows its title-only placeholder. Paired clients only (same gate as `/applist`). The resolve+fetch is
|
||||
/// blocking (disk + network), so it runs on a blocking thread off the async runtime.
|
||||
async fn h_appasset(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
if !peer_is_paired(&peer, &st) {
|
||||
tracing::warn!("appasset rejected — client is not paired");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let Some(appid) = q.get("appid").and_then(|s| s.parse::<u32>().ok()) else {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
};
|
||||
match tokio::task::spawn_blocking(move || super::apps::appasset_bytes(appid)).await {
|
||||
Ok(Some((bytes, ctype))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn h_launch(
|
||||
State(st): State<Arc<AppState>>,
|
||||
peer: Option<Extension<PeerCertFingerprint>>,
|
||||
|
||||
@@ -357,12 +357,17 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
Some("2") => Codec::Av1,
|
||||
_ => Codec::H264,
|
||||
};
|
||||
// 10-bit/HDR request flag. We never advertise the Main10 SCM bits, so a compliant
|
||||
// client can't ask — if one does anyway, stream 8-bit SDR rather than failing.
|
||||
if parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0 {
|
||||
// 10-bit/HDR request (Moonlight sets `dynamicRangeMode != 0` only when it both saw our Main10 SCM
|
||||
// bit AND the user enabled HDR). Honor it only when the host can actually deliver Main10 (Windows +
|
||||
// PUNKTFUNK_10BIT, `host_hdr_capable`); when honored, the video path proactively enables advanced
|
||||
// color on the virtual display so a PQ stream flows even from an SDR desktop. A request we can't
|
||||
// honor degrades to 8-bit SDR (and a desktop that is ALREADY HDR still streams PQ regardless, since
|
||||
// the IDD-push capturer follows the display).
|
||||
let hdr_requested = parse_u("x-nv-video[0].dynamicRangeMode").unwrap_or(0) != 0;
|
||||
let hdr = hdr_requested && crate::gamestream::host_hdr_capable();
|
||||
if hdr_requested && !hdr {
|
||||
tracing::warn!(
|
||||
"client requested HDR/10-bit (dynamicRangeMode != 0) — not advertised/supported, \
|
||||
streaming 8-bit SDR"
|
||||
"client requested HDR (dynamicRangeMode != 0) but host is not HDR-capable — streaming 8-bit SDR"
|
||||
);
|
||||
}
|
||||
// Parity floor the client asks for (protects small frames); clamp to a sane max.
|
||||
@@ -377,6 +382,7 @@ fn stream_config(map: &HashMap<String, String>) -> Option<StreamConfig> {
|
||||
bitrate_kbps,
|
||||
codec,
|
||||
min_fec,
|
||||
hdr,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +43,33 @@ pub fn serverinfo_xml(host: &Host, https: bool, paired: bool) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// The `<ServerCodecModeSupport>` mask to advertise. On the VAAPI (AMD/Intel) backend it reflects
|
||||
/// what the GPU can ACTUALLY encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a
|
||||
/// Moonlight client never negotiates a codec the encoder can't open. NVENC and Windows keep the
|
||||
/// Moonlight-validated static superset.
|
||||
/// The `<ServerCodecModeSupport>` mask to advertise: the SDR baseline ([`base_codec_mode_support`]) plus
|
||||
/// the HEVC Main10 (HDR) bit when the host can actually deliver HDR ([`apply_hdr`] /
|
||||
/// [`crate::gamestream::host_hdr_capable`]). Without the Main10 bit Moonlight never offers its HDR
|
||||
/// toggle; with it, enabling HDR client-side negotiates Main10 and the IDD-push path streams BT.2020 PQ.
|
||||
fn codec_mode_support() -> u32 {
|
||||
apply_hdr(
|
||||
base_codec_mode_support(),
|
||||
crate::gamestream::host_hdr_capable(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Add the HEVC Main10 (HDR) bit to `base` when `hdr` and HEVC is advertised — pure so the
|
||||
/// HDR-layering is unit-testable without a GPU. (HDR streaming uses HEVC Main10; AV1 Main10 is left
|
||||
/// off until the GameStream AV1 path is live-confirmed.)
|
||||
fn apply_hdr(base: u32, hdr: bool) -> u32 {
|
||||
if hdr && base & super::SCM_HEVC != 0 {
|
||||
base | super::SCM_HEVC_MAIN10
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
|
||||
/// The **SDR baseline** mask. On the VAAPI (AMD/Intel) backend it reflects what the GPU can ACTUALLY
|
||||
/// encode (probed — AV1 is narrow, and an old iGPU might lack HEVC), so a Moonlight client never
|
||||
/// negotiates a codec the encoder can't open. NVENC and the GPU-less software path keep the
|
||||
/// Moonlight-validated static superset. HDR (Main10) is layered on by [`codec_mode_support`].
|
||||
fn base_codec_mode_support() -> u32 {
|
||||
#[cfg(target_os = "linux")]
|
||||
if crate::encode::linux_zero_copy_is_vaapi() {
|
||||
if let Some(m) = probed_mask(crate::encode::vaapi_codec_support()) {
|
||||
@@ -108,6 +130,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_hdr_adds_main10_only_when_capable_and_hevc() {
|
||||
// HDR-capable + HEVC advertised → Main10 added.
|
||||
assert_eq!(
|
||||
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, true),
|
||||
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8 | SCM_HEVC_MAIN10
|
||||
);
|
||||
// Not HDR-capable → baseline unchanged (no HDR claim).
|
||||
assert_eq!(
|
||||
apply_hdr(SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8, false),
|
||||
SCM_H264 | SCM_HEVC | SCM_AV1_MAIN8
|
||||
);
|
||||
// HDR-capable but a GPU with no HEVC at all → no Main10 (you can't do Main10 without HEVC).
|
||||
assert_eq!(apply_hdr(SCM_H264, true), SCM_H264);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serverinfo_xml_carries_codec_mask() {
|
||||
let host = Host {
|
||||
|
||||
@@ -28,6 +28,10 @@ pub struct StreamConfig {
|
||||
pub codec: Codec,
|
||||
/// Client's `x-nv-vqos[0].fec.minRequiredFecPackets` — parity floor per FEC block.
|
||||
pub min_fec: u8,
|
||||
/// Client requested HDR (`dynamicRangeMode != 0`) AND the host can deliver it ([`host_hdr_capable`]).
|
||||
/// Drives the capturer's proactive advanced-color enable; the encoder picks Main10 from the captured
|
||||
/// (P010) frame format. Always `false` on a non-HDR host, so the SDR path is unchanged.
|
||||
pub hdr: bool,
|
||||
}
|
||||
|
||||
/// Slot for the persistent screen capturer, shared with the control plane and reused across
|
||||
@@ -137,7 +141,15 @@ fn run(
|
||||
let launch_here = compositor != crate::vdisplay::Compositor::Gamescope;
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
if launch_here {
|
||||
if let Some(cmd) = app
|
||||
// A library title (Steam/Epic/GOG/Xbox/custom, surfaced in /applist) carries its
|
||||
// store-qualified id — resolve + launch it against the host's OWN library (the client can
|
||||
// only pick an existing title, never inject a command). An apps.json entry instead carries
|
||||
// an operator-typed `cmd`. Library id wins when both are set.
|
||||
if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) {
|
||||
if let Err(e) = crate::library::launch_gamestream_library(lib_id) {
|
||||
tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title");
|
||||
}
|
||||
} else if let Some(cmd) = app
|
||||
.and_then(|a| a.cmd.as_deref())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
{
|
||||
@@ -245,11 +257,13 @@ fn open_gs_virtual_source(
|
||||
refresh_hz: cfg.fps,
|
||||
})
|
||||
.context("create virtual output at client resolution")?;
|
||||
// want_hdr=false: GameStream HDR is not negotiated into StreamConfig here (the default WGC backend
|
||||
// still auto-detects HDR from the output colorspace; only the opt-in IDD-push path streams SDR).
|
||||
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
|
||||
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
|
||||
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
|
||||
// capturer follows the display). No-op on Linux (8-bit, and `cfg.hdr` is always false there).
|
||||
let capturer = capture::capture_virtual_output(
|
||||
vout,
|
||||
capture::OutputFormat::resolve(false),
|
||||
capture::OutputFormat::resolve(cfg.hdr),
|
||||
crate::session_plan::CaptureBackend::resolve(),
|
||||
)
|
||||
.context("capture virtual output")?;
|
||||
@@ -257,6 +271,19 @@ fn open_gs_virtual_source(
|
||||
Ok((capturer, compositor))
|
||||
}
|
||||
|
||||
/// The encoder bit depth implied by the captured frame's pixel format: a 10-bit (HDR) source — the
|
||||
/// Windows IDD-push capturer's `P010`/`Rgb10a2` when the desktop is HDR — opens NVENC as HEVC Main10
|
||||
/// (BT.2020 PQ); everything else is 8-bit. The encoder backends already key the real profile off the
|
||||
/// `format`, so this just keeps the `bit_depth` argument honest (the old hard-coded `8` mislabeled an
|
||||
/// HDR stream that the format had already promoted to 10-bit).
|
||||
fn gs_bit_depth(format: crate::capture::PixelFormat) -> u8 {
|
||||
use crate::capture::PixelFormat;
|
||||
match format {
|
||||
PixelFormat::P010 | PixelFormat::Rgb10a2 => 10,
|
||||
_ => 8,
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame's packets, handed from the encode thread to the send thread.
|
||||
type PacketBatch = Vec<Vec<u8>>;
|
||||
|
||||
@@ -442,9 +469,10 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8, // GameStream/Moonlight path: 8-bit (its own codec negotiation)
|
||||
// 8-bit SDR, or 10-bit when the captured frame is HDR (P010) — see `gs_bit_depth`.
|
||||
gs_bit_depth(frame.format),
|
||||
// GameStream/Moonlight stays 4:2:0 — stock Moonlight clients can't decode 4:4:4, and the
|
||||
// protocol has no chroma negotiation. 4:4:4 is punktfunk/1-native only.
|
||||
// Windows IDD-push capturer can't yet deliver full-chroma frames. 4:4:4 is punktfunk/1-native only.
|
||||
encode::ChromaFormat::Yuv420,
|
||||
)
|
||||
.context("open video encoder for stream")?;
|
||||
@@ -574,7 +602,7 @@ fn stream_body(
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
8,
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0
|
||||
)
|
||||
.context("reopen encoder after rebuild")?;
|
||||
|
||||
@@ -1116,6 +1116,58 @@ fn fetch_json(url: &str) -> Option<serde_json::Value> {
|
||||
serde_json::from_str(&body).ok()
|
||||
}
|
||||
|
||||
/// Fetch one image URL for the GameStream `/appasset` cover proxy, as `(bytes, content-type)`. Handles
|
||||
/// `data:` URLs (Lutris inlines art that way) by decoding inline, and `http(s)` URLs by a bounded GET
|
||||
/// (8 MiB cap so a hostile/huge art URL can't balloon host memory). `None` on any non-image scheme,
|
||||
/// network/decoder error, or empty body. Blocking (ureq) — call off the async runtime.
|
||||
fn fetch_image(url: &str) -> Option<(Vec<u8>, String)> {
|
||||
use base64::Engine as _;
|
||||
use std::io::Read as _;
|
||||
if let Some(rest) = url.strip_prefix("data:") {
|
||||
// data:[<mediatype>][;base64],<payload>
|
||||
let (meta, data) = rest.split_once(',')?;
|
||||
let ctype = meta
|
||||
.split(';')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or("image/jpeg")
|
||||
.to_string();
|
||||
let bytes = if meta.contains(";base64") {
|
||||
base64::engine::general_purpose::STANDARD.decode(data).ok()?
|
||||
} else {
|
||||
data.as_bytes().to_vec()
|
||||
};
|
||||
return (!bytes.is_empty()).then_some((bytes, ctype));
|
||||
}
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) {
|
||||
return None;
|
||||
}
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build();
|
||||
let resp = agent.get(url).call().ok()?;
|
||||
let ctype = resp.header("Content-Type").unwrap_or("image/jpeg").to_string();
|
||||
let mut bytes = Vec::new();
|
||||
resp.into_reader()
|
||||
.take(8 * 1024 * 1024)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
(!bytes.is_empty()).then_some((bytes, ctype))
|
||||
}
|
||||
|
||||
/// Resolve + fetch the best box-art cover for a library id (the GameStream `/appasset` proxy — Moonlight
|
||||
/// fetches per-app covers from the HOST, not the CDN, so we proxy the bytes). Tries the portrait (tall
|
||||
/// capsule Moonlight wants) → header → hero → logo, returning the first that fetches as
|
||||
/// `(bytes, content-type)`. Resolves the id against the host's OWN library. Blocking — call off the
|
||||
/// async runtime (e.g. `spawn_blocking`).
|
||||
pub fn fetch_box_art(id: &str) -> Option<(Vec<u8>, String)> {
|
||||
let g = all_games().into_iter().find(|g| g.id == id)?;
|
||||
[g.art.portrait, g.art.header, g.art.hero, g.art.logo]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|url| fetch_image(&url))
|
||||
}
|
||||
|
||||
/// Make a protocol-relative URL (`//host/...`, common in GOG + MS catalog responses) absolute https.
|
||||
fn abs_url(u: &str) -> String {
|
||||
u.strip_prefix("//")
|
||||
@@ -1487,6 +1539,25 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch a library title chosen from the **GameStream `/applist`** (the store-qualified id is carried
|
||||
/// on the `AppEntry`, resolved from the numeric Moonlight appid). Windows spawns it into the interactive
|
||||
/// user session ([`launch_title`]); Linux resolves its shell command ([`launch_command`]) and runs it
|
||||
/// into the live session ([`launch_gamestream_command`]). The id is resolved against the host's OWN
|
||||
/// library, so a client can only ever pick an existing title — never inject a command.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
pub fn launch_gamestream_library(id: &str) -> Result<()> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
launch_title(id)
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let cmd = launch_command(id)
|
||||
.ok_or_else(|| anyhow::anyhow!("library id '{id}' has no launch recipe"))?;
|
||||
launch_gamestream_command(&cmd)
|
||||
}
|
||||
}
|
||||
|
||||
/// The full library: every store's titles merged + the custom entries, sorted by title.
|
||||
pub fn all_games() -> Vec<GameEntry> {
|
||||
let mut games = SteamProvider.list();
|
||||
@@ -1608,6 +1679,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_image_decodes_data_url() {
|
||||
// "Hi" base64 == "SGk=" — the data: branch is pure (no network), so it's deterministic.
|
||||
let (bytes, ctype) = fetch_image("data:image/png;base64,SGk=").expect("data url decodes");
|
||||
assert_eq!(bytes, b"Hi");
|
||||
assert_eq!(ctype, "image/png");
|
||||
// A non-image scheme is rejected (no launcher art ever points at file://, but be defensive).
|
||||
assert!(fetch_image("file:///etc/passwd").is_none());
|
||||
// Empty payload → None (never serve a 0-byte cover).
|
||||
assert!(fetch_image("data:image/png;base64,").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_entry_maps_to_game_entry() {
|
||||
let g: GameEntry = CustomEntry {
|
||||
|
||||
Reference in New Issue
Block a user