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();
|
||||
|
||||
Reference in New Issue
Block a user