From 028462ef6d8a402f3bbafceaf8b1d871457a7b92 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 01:26:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(vdisplay/gamescope):=20a=20Game=20Mode=20s?= =?UTF-8?q?ession=20drives=20a=20real=20head=20=E2=80=94=20list=20it,=20an?= =?UTF-8?q?d=20mirror=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-monitor capture feature shipped with gamescope written off as "nested, therefore no physical heads of its own", and `monitors::list` returned a hard-coded empty Vec for it. That premise holds for a gamescope nested in another compositor and for the headless ones this crate spawns — but not for the deployment people actually stream from: a Bazzite/SteamOS Game Mode session is the DRM master and drives the TV directly. On such a box the console's picker was permanently empty, on exactly the hardware the feature was asked for. `gamescope/heads.rs` answers the narrow question instead — is this gamescope on the DRM backend, and which connector is it lighting? — from `/proc//cmdline` (the backend flag and `--prefer-output`; gamescope publishes no protocol that reports its own output) and `/sys/class/drm` (what is plugged in, plus each connector's EDID for the picker label and the panel's preferred timing). The nested/headless shapes still answer an empty list, which is what they always meant. Mirroring needs no new capture code: gamescope composites its one head into a PipeWire node it already publishes, so the mirror arm is an ATTACH to that node — the gaming session is not stopped, relaunched or re-moded, which is the whole difference between "stream the panel I am looking at" and the MANAGED takeover that deliberately blanks it. Refresh comes from the EDID and never from `--nested-refresh`: that flag is the rate gamescope composites the nested game at, and reading it as the connector's rate would let a session launched at 60 cap a 120 Hz TV for the whole stream. Verified against home-bazzite-1's live EDID: manufacturer `1e6d` -> GSM, name descriptor -> "LG TV SSCR2", preferred timing 594 MHz / 4400x2250 -> 3840x2160@60. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/vdisplay/linux/gamescope.rs | 50 ++ .../src/vdisplay/linux/gamescope/heads.rs | 546 ++++++++++++++++++ crates/pf-vdisplay/src/vdisplay/mirror.rs | 12 +- crates/pf-vdisplay/src/vdisplay/monitors.rs | 9 +- 4 files changed, 611 insertions(+), 6 deletions(-) create mode 100644 crates/pf-vdisplay/src/vdisplay/linux/gamescope/heads.rs diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index ef7f533d..f3e5610a 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -21,6 +21,8 @@ use std::time::{Duration, Instant}; #[path = "gamescope/discovery.rs"] mod discovery; +#[path = "gamescope/heads.rs"] +mod heads; #[path = "gamescope/splash.rs"] mod splash; use discovery::{ @@ -31,6 +33,7 @@ pub(crate) use discovery::{ game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available, note_spawn_flags_lost, steam_appid_from_launch, wait_for_steam_game_exit, SteamGameWatch, }; +pub(crate) use heads::list_monitors; pub(crate) use splash::run as splash_run; /// The gamescope virtual-display driver. Three modes by env, in precedence order: @@ -2156,6 +2159,53 @@ fn point_injector_at_eis() { } } +/// Mirror the physical head this gamescope session is driving (`vdisplay::mirror`'s gamescope arm). +/// +/// The other backends mirror a head by *starting a new cast* addressed by connector name. gamescope +/// has no such call and needs none: it composites its one head into a PipeWire node it already +/// publishes, so mirroring is an ATTACH to that node — the same node the `PUNKTFUNK_GAMESCOPE_NODE` +/// route uses, reached here without the sub-mode ladder because a monitor pin has already decided +/// the question the ladder exists to answer. +/// +/// **What makes this the mirror rather than the takeover**: nothing is stopped, nothing is +/// relaunched, and no mode is imposed. The MANAGED route would tear the TV's autologin session down +/// and rebuild it headless at the client's mode — correct when the operator wants the box's screen +/// to go dark and the client to drive it, and exactly wrong when they asked to stream the panel +/// they are looking at. `keepalive` is therefore `()`: we did not create this session and must not +/// end it (`DisplayOwnership::External`, applied by the caller). +/// +/// `hw_cursor` is inert here, and deliberately so rather than silently dropped: whether gamescope's +/// pointer reaches the stream is fixed by the SPAWN flags of the session that is already running +/// (`--pipewire-composite-cursor`, see `gamescope_can_composite_cursor`), not negotiable per cast. +/// The session plan reads that same fact and arranges the XFixes reconstruction when it is absent. +pub(crate) fn stream_existing_output( + connector: &str, + hw_cursor: bool, +) -> Result { + let node_id = find_gamescope_node().ok_or_else(|| { + anyhow!( + "gamescope is driving {connector:?} but publishes no PipeWire Video/Source node — the \ + session may still be starting, or this gamescope was built without PipeWire support" + ) + })?; + // Absolute input lands through gamescope's own EIS socket, as it does on every other gamescope + // route. The host's `capture_monitor` anchor is a no-op for this backend (gamescope advertises + // a degenerate INT32_MAX region), so the output-size hint written here is what actually scales + // the client's normalized positions onto the head. + point_injector_at_eis(); + tracing::info!( + connector, + node_id, + hw_cursor, + "gamescope: mirroring the session's own head (attach — the gaming session is untouched)" + ); + Ok(crate::mirror::MirrorStream { + node_id, + remote_fd: None, + keepalive: Box::new(()), + }) +} + /// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs). fn gamescope_bin_wrapper_path() -> std::path::PathBuf { let base = crate::session::runtime_dir(); diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope/heads.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/heads.rs new file mode 100644 index 00000000..22bc667c --- /dev/null +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope/heads.rs @@ -0,0 +1,546 @@ +//! The physical head a gamescope session drives — the enumeration half of per-monitor capture. +//! +//! gamescope was originally written off here as "nested, therefore no physical heads of its own" +//! (`design/per-monitor-portal-capture.md` §2). That is true of a gamescope running *inside* +//! another compositor, and of the headless ones this crate spawns itself — but it is false for the +//! deployment most people actually stream from: a Bazzite/SteamOS **Game Mode session is the DRM +//! master**, driving the TV directly. On such a box the head exists, the operator can see it, and +//! refusing to list it meant the console's picker was permanently empty on exactly the hardware the +//! feature was asked for. +//! +//! So this module answers a narrow question — *is this gamescope on the DRM backend, and which +//! connector is it lighting?* — from two sources that need no cooperation from gamescope: +//! +//! * `/proc//cmdline`, for the backend flag and `--prefer-output` (gamescope has no protocol +//! that reports its own output; the [`gamescope_control`] extension only sets things), and +//! * `/sys/class/drm`, for which connectors are actually plugged in, plus each one's EDID. +//! +//! **Only the head gamescope drives is listed.** Mirroring here is an attach to gamescope's own +//! composited PipeWire node (see [`super::heads`]'s caller in `vdisplay::mirror`), so a connector +//! gamescope is *not* driving could never be streamed — offering it in a picker would be offering +//! something that cannot work. + +use crate::monitors::{describe, PhysicalMonitor}; +use std::path::Path; + +/// The head this box's gamescope session is driving, or empty when it drives none. +/// +/// Empty — never an error — for the three "no physical head" shapes, which are ordinary states and +/// not failures to reach anything: no gamescope running, a gamescope on a non-DRM backend +/// (`--backend headless`, or nested in a parent compositor), and a DRM gamescope with nothing +/// plugged in. That matches [`crate::monitors::list`]'s contract, where `Ok(vec![])` means "asked, +/// and there are none". +pub(crate) fn list_monitors() -> anyhow::Result> { + Ok(heads_under( + Path::new("/sys/class/drm"), + &super::gamescope_argvs(), + super::current_gamescope_output_size(), + )) +} + +/// [`list_monitors`] against an arbitrary sysfs root and a supplied argv set — the unit-testable +/// core. `output_size` is gamescope's own `-W`/`-H`, which OUTRANKS the EDID's preferred timing +/// because it is the size the capture node actually produces. +fn heads_under( + base: &Path, + argvs: &[Vec], + output_size: Option<(u32, u32)>, +) -> Vec { + // A gamescope that isn't on DRM has no head of its own. Any DRM-backed one qualifies the box: + // a Deck streaming from Game Mode often has a second, nested gamescope running the game inside + // the session one, and that child must not disqualify its parent. + let Some(argv) = argvs.iter().find(|a| drives_drm(a)) else { + return Vec::new(); + }; + let connected = connected_connectors(base); + if connected.is_empty() { + return Vec::new(); + } + let driven = resolve_driven(&connected, prefer_output(argv)); + driven + .into_iter() + .map(|c| { + let edid = std::fs::read(base.join(&c.dir).join("edid")) + .ok() + .and_then(|b| parse_edid(&b)); + let (make, model) = edid + .as_ref() + .map(|e| (e.make.clone(), e.model.clone())) + .unwrap_or_default(); + // Size: what the capture node produces (`-W`/`-H`) beats the panel's preferred timing, + // since that is what the client will actually decode. Refresh only ever comes from the + // EDID — gamescope's `-r`/`--nested-refresh` is the rate it composites the NESTED game + // at, not the rate the connector runs, and reading it as the latter would tell the + // pacer to cap a 120 Hz TV at whatever the session happened to be launched with. + let (w, h) = output_size + .or_else(|| edid.as_ref().map(|e| (e.width, e.height))) + .or_else(|| preferred_sysfs_mode(base, &c.dir)) + .unwrap_or((0, 0)); + PhysicalMonitor { + description: describe(&make, &model, &c.connector), + connector: c.connector, + width: w, + height: h, + refresh_mhz: edid.as_ref().map(|e| e.refresh_mhz).unwrap_or(0), + // gamescope composites ONE output; there is no global space to be offset in, so + // the origin is the origin. (`monitors`' identity key degenerates here, which is + // sound precisely because there is never more than one head to tell apart.) + x: 0, + y: 0, + scale: 1.0, + primary: true, + enabled: c.enabled, + // Never ours: a gamescope WE created is headless, and `drives_drm` excluded it. + managed: false, + } + }) + .collect() +} + +/// One connected DRM connector, as sysfs presents it. +struct Connector { + /// Directory name — `card1-HDMI-A-1`. + dir: String, + /// Connector name with the `cardN-` prefix stripped — `HDMI-A-1`, the id an operator pins. + connector: String, + enabled: bool, +} + +/// Every plugged-in connector under `base`, sorted by name so the list is stable across calls +/// (`read_dir` order is not). +fn connected_connectors(base: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(base) else { + return Vec::new(); + }; + let mut out: Vec = entries + .flatten() + .filter_map(|e| { + let dir = e.file_name().to_str()?.to_string(); + // `cardN-`; the render/device nodes (`card1`, `renderD128`) have no dash and + // no status file, and a connector name itself contains dashes (`HDMI-A-1`). + let (card, connector) = dir.split_once('-')?; + if !card.starts_with("card") || !card[4..].bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let status = std::fs::read_to_string(e.path().join("status")).ok()?; + if status.trim() != "connected" { + return None; + } + let enabled = std::fs::read_to_string(e.path().join("enabled")) + .map(|s| s.trim() == "enabled") + .unwrap_or(true); + Some(Connector { + connector: connector.to_string(), + dir, + enabled, + }) + }) + .collect(); + out.sort_by(|a, b| a.connector.cmp(&b.connector)); + out +} + +/// Which of `connected` is gamescope lighting? +/// +/// `--prefer-output` is gamescope's own answer and is taken when it names something plugged in — +/// it is a comma-separated preference list (`*,eDP-1` on a Deck), so `*` ("whatever you find") is +/// skipped rather than matched. Failing that, a single connected head is unambiguous. +/// +/// When neither settles it, every connected head is returned. That is deliberately NOT a guess: +/// mirroring attaches to gamescope's composited node, so the *stream* is correct whichever row the +/// operator picks — only the label could be off, and showing the plausible labels beats showing an +/// empty picker on a box that demonstrably has a screen. +fn resolve_driven(connected: &[Connector], prefer: Option<&str>) -> Vec { + let clone = |c: &Connector| Connector { + dir: c.dir.clone(), + connector: c.connector.clone(), + enabled: c.enabled, + }; + if let Some(list) = prefer { + for want in list.split(',').map(str::trim).filter(|w| !w.is_empty()) { + if want == "*" { + continue; + } + if let Some(c) = connected + .iter() + .find(|c| c.connector.eq_ignore_ascii_case(want)) + { + return vec![clone(c)]; + } + } + } + if connected.len() == 1 { + return vec![clone(&connected[0])]; + } + tracing::debug!( + heads = connected.len(), + "gamescope: --prefer-output does not name a connected head — listing all of them (the \ + mirror attaches to the composited node either way)" + ); + connected.iter().map(clone).collect() +} + +/// Is this gamescope on the DRM backend — i.e. does it own a physical connector? +/// +/// gamescope picks DRM when no `--backend` says otherwise, so ABSENCE of the flag is the common +/// yes (a Bazzite session's argv carries `--prefer-output` and no backend at all). `headless` is +/// what this crate spawns and what its own SteamOS takeover writes; `wayland`/`sdl`/`x11` are +/// nested. `-b` is the short form. +fn drives_drm(argv: &[String]) -> bool { + match backend_flag(argv) { + Some(b) => b.eq_ignore_ascii_case("drm"), + None => true, + } +} + +fn backend_flag(argv: &[String]) -> Option<&str> { + flag_value(argv, &["--backend", "-b"]) +} + +fn prefer_output(argv: &[String]) -> Option<&str> { + flag_value(argv, &["--prefer-output", "-O"]) +} + +/// The value following the first of `names` present in `argv`, in both `--flag value` and +/// `--flag=value` spellings. +fn flag_value<'a>(argv: &'a [String], names: &[&str]) -> Option<&'a str> { + argv.iter().enumerate().find_map(|(i, a)| { + if let Some((k, v)) = a.split_once('=') { + if names.contains(&k) { + return Some(v); + } + } + if names.contains(&a.as_str()) { + return argv.get(i + 1).map(|s| s.as_str()); + } + None + }) +} + +/// The connector's preferred mode from sysfs (`modes`, most-preferred first, `WIDTHxHEIGHT`). +/// Last-resort size when there is neither a `-W`/`-H` nor a readable EDID. +fn preferred_sysfs_mode(base: &Path, dir: &str) -> Option<(u32, u32)> { + let modes = std::fs::read_to_string(base.join(dir).join("modes")).ok()?; + let first = modes.lines().next()?.trim(); + let (w, h) = first.split_once('x')?; + Some((w.parse().ok()?, h.trim().parse().ok()?)) +} + +/// The fields a picker needs out of an EDID base block. +struct Edid { + make: String, + model: String, + width: u32, + height: u32, + refresh_mhz: u32, +} + +/// Parse an EDID 1.x base block: the manufacturer id, the monitor-name descriptor, and the +/// preferred detailed timing. +/// +/// Only the first 128-byte block is read. Extension blocks (CTA-861) can carry *more* modes, but +/// never a better answer to "what is this panel called and what is its preferred timing", which is +/// all a picker row needs. +fn parse_edid(bytes: &[u8]) -> Option { + if bytes.len() < 128 || bytes[..8] != [0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00] { + return None; + } + // Manufacturer: three 5-bit letters packed big-endian, 1 = 'A' (bit 15 reserved). This is the + // PnP id (`SAM`, `LGD`) rather than a marketing name — the same thing sway and KWin report, + // and `describe` pairs it with the model string. + let m = u16::from_be_bytes([bytes[8], bytes[9]]); + let letter = |shift: u16| -> Option { + let v = ((m >> shift) & 0x1F) as u8; + (1..=26).contains(&v).then(|| (b'A' + v - 1) as char) + }; + let make: String = match (letter(10), letter(5), letter(0)) { + (Some(a), Some(b), Some(c)) => [a, b, c].iter().collect(), + // A zeroed/garbage id is not worth showing; `describe` falls back to the connector. + _ => String::new(), + }; + let mut model = String::new(); + let mut timing: Option<(u32, u32, u32)> = None; + // Four 18-byte descriptors. The FIRST is the preferred timing by definition; the others may be + // timings too, or tagged text blocks (a zero pixel clock is the tag marker). + for off in [54usize, 72, 90, 108] { + let d = &bytes[off..off + 18]; + let pixel_clock = u16::from_le_bytes([d[0], d[1]]); + if pixel_clock == 0 { + // 0xFC = monitor name, up to 13 bytes, 0x0A-terminated and space-padded. + if d[3] == 0xFC && model.is_empty() { + let raw = &d[5..18]; + let end = raw.iter().position(|&b| b == 0x0A).unwrap_or(raw.len()); + model = String::from_utf8_lossy(&raw[..end]).trim().to_string(); + } + continue; + } + if timing.is_some() { + continue; + } + // Split-nibble encoding: the high 4 bits of the upper byte extend hactive, the low 4 hblank + // (likewise vactive/vblank). + let hactive = d[2] as u32 | ((d[4] as u32 & 0xF0) << 4); + let hblank = d[3] as u32 | ((d[4] as u32 & 0x0F) << 8); + let vactive = d[5] as u32 | ((d[7] as u32 & 0xF0) << 4); + let vblank = d[6] as u32 | ((d[7] as u32 & 0x0F) << 8); + let (htotal, vtotal) = (hactive + hblank, vactive + vblank); + if hactive == 0 || vactive == 0 || htotal == 0 || vtotal == 0 { + continue; + } + // Pixel clock is in 10 kHz units; ×10_000 gives Hz, and the extra ×1000 gives mHz — done + // in u64 so a 4K timing's htotal×vtotal cannot overflow the intermediate. + let refresh_mhz = + ((pixel_clock as u64 * 10_000_000) / (htotal as u64 * vtotal as u64)) as u32; + timing = Some((hactive, vactive, refresh_mhz)); + } + let (width, height, refresh_mhz) = timing.unwrap_or((0, 0, 0)); + Some(Edid { + make, + model, + width, + height, + refresh_mhz, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn argv(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + /// A sysfs tree with the device/render nodes a real `/sys/class/drm` also holds, so the + /// connector filter is exercised against the noise it has to survive. + fn sysfs(name: &str, connectors: &[(&str, &str, &str)]) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!("pf-gs-heads-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for n in ["card1", "renderD128"] { + std::fs::create_dir_all(base.join(n)).unwrap(); + } + for (dir, status, enabled) in connectors { + let d = base.join(dir); + std::fs::create_dir_all(&d).unwrap(); + std::fs::write(d.join("status"), status).unwrap(); + std::fs::write(d.join("enabled"), enabled).unwrap(); + } + base + } + + /// The bug this module exists to fix: a Bazzite Game Mode session (no `--backend`, so DRM) + /// driving a TV must report that TV, where the old code returned an empty list unconditionally. + #[test] + fn a_drm_backed_session_reports_the_head_it_drives() { + let base = sysfs( + "drm", + &[ + ("card1-HDMI-A-1", "connected\n", "enabled\n"), + ("card1-DP-1", "disconnected\n", "disabled\n"), + ], + ); + let heads = heads_under( + &base, + &[argv("/usr/bin/gamescope --prefer-output HDMI-A-1 --steam")], + None, + ); + assert_eq!(heads.len(), 1); + assert_eq!(heads[0].connector, "HDMI-A-1"); + assert!(heads[0].enabled && heads[0].primary && !heads[0].managed); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// The original premise, which stays true and must keep returning empty: a gamescope we spawned + /// headless (or one nested in a parent compositor) owns no connector, even on a box whose TV is + /// plugged in. This is the state a punktfunk takeover leaves the box in mid-stream. + #[test] + fn a_headless_or_nested_session_reports_nothing() { + let base = sysfs( + "headless", + &[("card1-HDMI-A-1", "connected\n", "enabled\n")], + ); + for a in [ + "gamescope --backend headless -W 1920 -H 1080", + "gamescope --backend=headless", + "gamescope -b wayland", + "gamescope --backend sdl", + ] { + assert!( + heads_under(&base, &[argv(a)], None).is_empty(), + "expected no heads for {a:?}" + ); + } + // No gamescope at all is the same answer, not an error. + assert!(heads_under(&base, &[], None).is_empty()); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// A Deck in Game Mode runs a SECOND gamescope nested inside the session one for the game. The + /// nested child must not mask the parent's connector. + #[test] + fn a_nested_child_does_not_disqualify_its_drm_parent() { + let base = sysfs("nested", &[("card1-eDP-1", "connected\n", "enabled\n")]); + let heads = heads_under( + &base, + &[ + argv("gamescope --backend wayland -W 1280 -H 800"), + argv("/usr/bin/gamescope --prefer-output *,eDP-1 --steam"), + ], + None, + ); + assert_eq!(heads.len(), 1); + assert_eq!(heads[0].connector, "eDP-1"); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// `*` means "whatever you find" and must not be matched against a connector name; the next + /// entry in the preference list is the real answer. + #[test] + fn prefer_output_skips_the_wildcard_and_picks_the_named_head() { + let base = sysfs( + "wildcard", + &[ + ("card1-eDP-1", "connected\n", "enabled\n"), + ("card1-HDMI-A-1", "connected\n", "enabled\n"), + ], + ); + let heads = heads_under(&base, &[argv("gamescope --prefer-output *,eDP-1")], None); + assert_eq!(heads.len(), 1); + assert_eq!(heads[0].connector, "eDP-1"); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// Two heads and nothing to choose between them: list both rather than guess or show nothing — + /// the mirror attaches to the composited node, so any row streams the right pixels. + #[test] + fn an_undecidable_multi_head_box_lists_every_connected_head() { + let base = sysfs( + "ambiguous", + &[ + ("card1-eDP-1", "connected\n", "enabled\n"), + ("card1-HDMI-A-1", "connected\n", "enabled\n"), + ], + ); + let heads = heads_under(&base, &[argv("gamescope --steam")], None); + assert_eq!( + heads + .iter() + .map(|h| h.connector.as_str()) + .collect::>(), + ["HDMI-A-1", "eDP-1"], + "sorted, so the picker order is stable" + ); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// A DRM gamescope with nothing plugged in is a headless box — empty, not an error. + #[test] + fn a_drm_session_with_nothing_plugged_in_reports_nothing() { + let base = sysfs( + "unplugged", + &[("card1-HDMI-A-1", "disconnected\n", "disabled\n")], + ); + assert!(heads_under(&base, &[argv("gamescope --steam")], None).is_empty()); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// gamescope's own output size is what the capture node produces, so it outranks the panel's + /// preferred timing — a supersampled session must not advertise the TV's native size. + #[test] + fn the_capture_size_outranks_the_panels_preferred_timing() { + let base = sysfs("size", &[("card1-HDMI-A-1", "connected\n", "enabled\n")]); + let heads = heads_under( + &base, + &[argv("gamescope -W 2560 -H 1440 --prefer-output HDMI-A-1")], + Some((2560, 1440)), + ); + assert_eq!((heads[0].width, heads[0].height), (2560, 1440)); + std::fs::remove_dir_all(&base).unwrap(); + } + + /// A synthetic EDID: 1920x1080 @ 60 Hz preferred timing, make `ACM`, name "TEST PANEL". + fn edid_1080p60() -> Vec { + let mut e = vec![0u8; 128]; + e[..8].copy_from_slice(&[0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00]); + // 'A'=1, 'C'=3, 'M'=13 → (1<<10)|(3<<5)|13 + let id: u16 = (1 << 10) | (3 << 5) | 13; + e[8..10].copy_from_slice(&id.to_be_bytes()); + // Preferred detailed timing: 148.5 MHz = 14850 (10 kHz units); 1920+280 x 1080+45. + e[54..56].copy_from_slice(&14850u16.to_le_bytes()); + e[56] = 1920u32 as u8; // hactive low + e[57] = 280u32 as u8; // hblank low + e[58] = ((1920 >> 4) as u8 & 0xF0) | ((280 >> 8) as u8 & 0x0F); + e[59] = (1080 & 0xFF) as u8; // vactive low + e[60] = 45; // vblank low + e[61] = ((1080 >> 4) as u8 & 0xF0) | ((45 >> 8) as u8 & 0x0F); + // Second descriptor: monitor name. + e[72..74].copy_from_slice(&0u16.to_le_bytes()); + e[75] = 0xFC; + let name = b"TEST PANEL\x0a"; + e[77..77 + name.len()].copy_from_slice(name); + e + } + + #[test] + fn edid_yields_the_label_and_the_preferred_timing() { + let e = parse_edid(&edid_1080p60()).expect("valid EDID"); + assert_eq!(e.make, "ACM"); + assert_eq!(e.model, "TEST PANEL"); + assert_eq!((e.width, e.height), (1920, 1080)); + // 148_500_000 / (2200 * 1125) = 60.0 Hz + assert_eq!(e.refresh_mhz, 60_000); + assert_eq!(describe(&e.make, &e.model, "HDMI-A-1"), "ACM TEST PANEL"); + } + + /// Garbage must not become a picker row that lies; the caller falls back to the connector name. + #[test] + fn a_bad_edid_is_rejected_rather_than_half_parsed() { + assert!(parse_edid(&[0u8; 128]).is_none(), "no magic header"); + assert!(parse_edid(&[0u8; 12]).is_none(), "too short"); + } + + /// The refresh a mirrored TV runs at comes from the EDID — reading gamescope's + /// `--nested-refresh` as the connector's rate would let a session launched at 60 cap a 120 Hz + /// panel for the whole stream. + #[test] + fn refresh_comes_from_the_panel_not_from_the_nested_rate() { + let base = sysfs("refresh", &[("card1-HDMI-A-1", "connected\n", "enabled\n")]); + std::fs::write(base.join("card1-HDMI-A-1").join("edid"), edid_1080p60()).unwrap(); + let heads = heads_under( + &base, + &[argv( + "gamescope --nested-refresh 30 --prefer-output HDMI-A-1", + )], + None, + ); + assert_eq!(heads[0].refresh_mhz, 60_000); + assert_eq!(heads[0].mode_label(), "1920x1080@60"); + std::fs::remove_dir_all(&base).unwrap(); + } + + #[test] + fn flag_values_parse_in_both_spellings() { + assert_eq!( + backend_flag(&argv("gamescope --backend headless")), + Some("headless") + ); + assert_eq!(backend_flag(&argv("gamescope --backend=drm")), Some("drm")); + assert_eq!(backend_flag(&argv("gamescope -b sdl")), Some("sdl")); + assert_eq!(backend_flag(&argv("gamescope --steam")), None); + assert_eq!(prefer_output(&argv("gamescope -O DP-2")), Some("DP-2")); + } + + /// Sysfs `modes` is the last resort when a panel has no readable EDID (some TVs over a KVM). + #[test] + fn sysfs_modes_is_the_last_resort_size() { + let base = sysfs("modes", &[("card1-HDMI-A-1", "connected\n", "enabled\n")]); + std::fs::write( + base.join("card1-HDMI-A-1").join("modes"), + "3840x2160\n1920x1080\n", + ) + .unwrap(); + let heads = heads_under(&base, &[argv("gamescope --steam")], None); + assert_eq!((heads[0].width, heads[0].height), (3840, 2160)); + std::fs::remove_dir_all(&base).unwrap(); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/mirror.rs b/crates/pf-vdisplay/src/vdisplay/mirror.rs index 78c1043b..b054439f 100644 --- a/crates/pf-vdisplay/src/vdisplay/mirror.rs +++ b/crates/pf-vdisplay/src/vdisplay/mirror.rs @@ -93,9 +93,15 @@ impl VirtualDisplay for MirrorDisplay { Compositor::Hyprland => { crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)? } - // gamescope is nested — it has no physical heads of its own, and `monitors::list` - // already returned an empty set, so `resolve` failed before we got here. This arm - // exists for exhaustiveness, not as a reachable path. + // gamescope on the DRM backend (a Bazzite/SteamOS Game Mode session) DOES drive a real + // head — `monitors::list` reports it, and mirroring it is an attach to the composited + // node this session already publishes. A nested or headless gamescope reports no heads, + // so `resolve` fails above and this arm is never reached for one. + #[cfg(target_os = "linux")] + Compositor::Gamescope => { + crate::gamescope::stream_existing_output(&target.connector, self.hw_cursor)? + } + #[allow(unreachable_patterns)] other => bail!( "mirroring an existing monitor is not supported on the {} backend", other.id() diff --git a/crates/pf-vdisplay/src/vdisplay/monitors.rs b/crates/pf-vdisplay/src/vdisplay/monitors.rs index bc08bfa0..d46ede70 100644 --- a/crates/pf-vdisplay/src/vdisplay/monitors.rs +++ b/crates/pf-vdisplay/src/vdisplay/monitors.rs @@ -102,10 +102,13 @@ pub fn list(compositor: Compositor) -> Result> { Compositor::Wlroots => crate::wlroots::list_monitors(), #[cfg(target_os = "linux")] Compositor::Hyprland => crate::hyprland::list_monitors(), - // gamescope is nested: it has no physical heads of its own, and mirroring the desktop it - // runs on is a question for the compositor UNDER it. Not an error — just nothing to offer. + // gamescope is only *sometimes* nested. A Bazzite/SteamOS Game Mode session is the DRM + // master and drives a real connector; the ones this crate spawns are headless and drive + // none. `gamescope::list_monitors` tells those apart and answers an empty list — not an + // error — for the nested/headless shapes, which is what this arm used to hard-code for all + // of them (and why the picker was permanently empty on a TV box). #[cfg(target_os = "linux")] - Compositor::Gamescope => Ok(Vec::new()), + Compositor::Gamescope => crate::gamescope::list_monitors(), #[cfg(not(target_os = "linux"))] _ => bail!("physical-monitor enumeration is implemented for the Linux backends only"), }