feat(vdisplay/gamescope): a Game Mode session drives a real head — list it, and mirror it

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/<pid>/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) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:23:32 +02:00
co-authored by Claude Opus 5
parent 8fee1139f6
commit 028462ef6d
4 changed files with 611 additions and 6 deletions
@@ -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<crate::mirror::MirrorStream> {
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();
@@ -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/<pid>/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<Vec<PhysicalMonitor>> {
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<String>],
output_size: Option<(u32, u32)>,
) -> Vec<PhysicalMonitor> {
// 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<Connector> {
let Ok(entries) = std::fs::read_dir(base) else {
return Vec::new();
};
let mut out: Vec<Connector> = entries
.flatten()
.filter_map(|e| {
let dir = e.file_name().to_str()?.to_string();
// `cardN-<connector>`; 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<Connector> {
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<Edid> {
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<char> {
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<String> {
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::<Vec<_>>(),
["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<u8> {
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();
}
}
+9 -3
View File
@@ -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()
+6 -3
View File
@@ -102,10 +102,13 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
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"),
}