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:
@@ -21,6 +21,8 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
#[path = "gamescope/discovery.rs"]
|
#[path = "gamescope/discovery.rs"]
|
||||||
mod discovery;
|
mod discovery;
|
||||||
|
#[path = "gamescope/heads.rs"]
|
||||||
|
mod heads;
|
||||||
#[path = "gamescope/splash.rs"]
|
#[path = "gamescope/splash.rs"]
|
||||||
mod splash;
|
mod splash;
|
||||||
use discovery::{
|
use discovery::{
|
||||||
@@ -31,6 +33,7 @@ pub(crate) use discovery::{
|
|||||||
game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available,
|
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,
|
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;
|
pub(crate) use splash::run as splash_run;
|
||||||
|
|
||||||
/// The gamescope virtual-display driver. Three modes by env, in precedence order:
|
/// 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).
|
/// Path of the host-written `GAMESCOPE_BIN` wrapper (per-user, in tmpfs).
|
||||||
fn gamescope_bin_wrapper_path() -> std::path::PathBuf {
|
fn gamescope_bin_wrapper_path() -> std::path::PathBuf {
|
||||||
let base = crate::session::runtime_dir();
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -93,9 +93,15 @@ impl VirtualDisplay for MirrorDisplay {
|
|||||||
Compositor::Hyprland => {
|
Compositor::Hyprland => {
|
||||||
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
|
crate::hyprland::stream_existing_output(&target.connector, self.hw_cursor)?
|
||||||
}
|
}
|
||||||
// gamescope is nested — it has no physical heads of its own, and `monitors::list`
|
// gamescope on the DRM backend (a Bazzite/SteamOS Game Mode session) DOES drive a real
|
||||||
// already returned an empty set, so `resolve` failed before we got here. This arm
|
// head — `monitors::list` reports it, and mirroring it is an attach to the composited
|
||||||
// exists for exhaustiveness, not as a reachable path.
|
// 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!(
|
other => bail!(
|
||||||
"mirroring an existing monitor is not supported on the {} backend",
|
"mirroring an existing monitor is not supported on the {} backend",
|
||||||
other.id()
|
other.id()
|
||||||
|
|||||||
@@ -102,10 +102,13 @@ pub fn list(compositor: Compositor) -> Result<Vec<PhysicalMonitor>> {
|
|||||||
Compositor::Wlroots => crate::wlroots::list_monitors(),
|
Compositor::Wlroots => crate::wlroots::list_monitors(),
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
Compositor::Hyprland => crate::hyprland::list_monitors(),
|
Compositor::Hyprland => crate::hyprland::list_monitors(),
|
||||||
// gamescope is nested: it has no physical heads of its own, and mirroring the desktop it
|
// gamescope is only *sometimes* nested. A Bazzite/SteamOS Game Mode session is the DRM
|
||||||
// runs on is a question for the compositor UNDER it. Not an error — just nothing to offer.
|
// 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")]
|
#[cfg(target_os = "linux")]
|
||||||
Compositor::Gamescope => Ok(Vec::new()),
|
Compositor::Gamescope => crate::gamescope::list_monitors(),
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
_ => bail!("physical-monitor enumeration is implemented for the Linux backends only"),
|
_ => bail!("physical-monitor enumeration is implemented for the Linux backends only"),
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user