fix(kwin): select the custom mode KWin actually built, not the one we asked for
apple / swift (push) Successful in 1m19s
apple / screenshots (push) Successful in 6m41s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m3s
ci / bench (push) Successful in 5m33s
android / android (push) Successful in 11m38s
decky / build-publish (push) Successful in 19s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 14s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish (push) Successful in 9m6s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m43s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
deb / build-publish-host (push) Successful in 9m50s
arch / build-publish (push) Successful in 21m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m27s
docker / deploy-docs (push) Failing after 15s
ci / rust (push) Successful in 28m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m49s

A KDE host streaming to a phone-shaped client fell back to 60 Hz even though the
120 Hz mode was sitting right there in KDE's display list, one manual click away.

KWin generates every custom mode's timing with libxcvt, whose first step is
`hdisplay_rnd = hdisplay - (hdisplay % 8)`. A 2868x1320@120 request (iPhone 16
Pro Max) therefore becomes 2864x1320@119.92 — 4 px narrower, fractional refresh.
We then asked kscreen-doctor for `mode.2868x1320@120`; its `findMode` matches a
mode's id or its own `WxH@qRound(Hz)` name, so that string matched nothing, the
select silently no-op'd, the output stayed on its sacrificial birth mode, and
`size_applied` came back false → "KWin rejected the custom mode" → 60 Hz.
Widths like 1920/2560/3840 are all multiples of 8, which is why only clients with
phone-shaped panels ever hit this.

Resolve the mode out of the output's OWN list instead and address it by kscreen
mode id: exact height, width at most one cell narrower than asked, refresh within
1 Hz (which excludes the native 60 Hz entry). `set_custom_refresh` now returns the
whole achieved mode, and `create` reports that as the output's `preferred_mode` —
so the capturer's renegotiation gate waits for the size KWin will actually deliver
and the encoder opens against it, rather than starving on the requested one.

Also skip `addCustomMode` when a usable mode is already installed: kscreen-doctor
APPENDS to the list and KWin persists it per output name, so the old code grew the
user's display list by one entry per connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 00:55:36 +02:00
co-authored by Claude Opus 4.8
parent 24a24734eb
commit 3a33a69401
+304 -55
View File
@@ -210,14 +210,16 @@ impl VirtualDisplay for KwinDisplay {
// resize handling: when the source's texture size changes while recording, KWin re-runs // resize handling: when the source's texture size changes while recording, KWin re-runs
// `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live // `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live
// stream via `pw_stream_update_params`. So above 60 Hz the output is born at a // stream via `pw_stream_update_params`. So above 60 Hz the output is born at a
// SACRIFICIAL height: installing + selecting the real `WxH@hz` custom mode (supported on // SACRIFICIAL height: installing + selecting the real high-refresh custom mode (supported
// virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded // on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
// after the consumer connects trigger KWin's resize → a renegotiation to `WxH@hz`. The // after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never // capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process // builds against the birth mode. First cut shells out to kscreen-doctor; the in-process
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what // kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what
// KWin *actually* gave so the encoder paces to the real source rate. At ≤60 Hz there's // KWin *actually* gave — both the rate (so the encoder paces to the real source) and the
// nothing to install — the output is born at the real size and 60 Hz is the offer anyway. // size, which KWin's CVT generator may have aligned down (see `CVT_H_GRANULARITY`). At
// ≤60 Hz there's nothing to install — the output is born at the real size and 60 Hz is the
// offer anyway.
let want_high = mode.refresh_hz > 60; let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height }; let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?; let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
@@ -241,36 +243,52 @@ impl VirtualDisplay for KwinDisplay {
let mut addr = resolve_kscreen_addr(&name, width, birth_h); let mut addr = resolve_kscreen_addr(&name, width, birth_h);
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout) self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
let mut expect_exact_dims = false; let mut expect_exact_dims = false;
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
// `preferred_mode`, which is what the capturer's renegotiation gate waits for and what the
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
let mut final_dims = (width, height);
let achieved_hz = if want_high { let achieved_hz = if want_high {
let (achieved, size_applied) = let active = set_custom_refresh(width, height, mode.refresh_hz, &addr);
set_custom_refresh(width, height, mode.refresh_hz, &addr); // Accept only an active mode that IS our custom one: the exact requested height, and a
if size_applied { // width at or just below the request (a CVT alignment). That also proves the output
// Real mode selected: the recording stream will renegotiate to it (see above). // left the sacrificial birth size, so the recording stream will renegotiate to it.
expect_exact_dims = true; match active {
achieved Some((aw, ah, ahz))
} else { if ah == height && aw <= width && width - aw < CVT_H_GRANULARITY =>
// Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the {
// output is STUCK at the sacrificial birth size — unusable. Recreate plain at the expect_exact_dims = true;
// real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz). final_dims = (aw, ah);
tracing::warn!( ahz
"KWin rejected the custom mode — recreating the virtual output at the real \ }
size (60 Hz ceiling on this KWin)" other => {
); // Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the
stop.store(true, Ordering::Relaxed); // output is STUCK at the sacrificial birth size — unusable. Recreate plain at the
// Let KWin retire the doomed output before re-using its name. // real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz).
std::thread::sleep(Duration::from_millis(300)); tracing::warn!(
let (nid, st) = spawn_vout(width, height)?; active = ?other,
node_id = nid; requested_w = width,
stop = st; requested_h = height,
addr = resolve_kscreen_addr(&name, width, height); requested_hz = mode.refresh_hz,
self.last_name = Some(addr.clone()); "KWin rejected the custom mode — recreating the virtual output at the real \
tracing::info!( size (60 Hz ceiling on this KWin)"
node_id, );
width, stop.store(true, Ordering::Relaxed);
height, // Let KWin retire the doomed output before re-using its name.
"KWin virtual output ready (fallback)" std::thread::sleep(Duration::from_millis(300));
); let (nid, st) = spawn_vout(width, height)?;
60 node_id = nid;
stop = st;
addr = resolve_kscreen_addr(&name, width, height);
self.last_name = Some(addr.clone());
tracing::info!(
node_id,
width,
height,
"KWin virtual output ready (fallback)"
);
60
}
} }
} else { } else {
mode.refresh_hz mode.refresh_hz
@@ -302,7 +320,7 @@ impl VirtualDisplay for KwinDisplay {
// (it owns the display group, so it computes auto-row / manual placement over the whole group). // (it owns the display group, so it computes auto-row / manual placement over the whole group).
let mut out = VirtualOutput::owned( let mut out = VirtualOutput::owned(
node_id, node_id,
Some((mode.width, mode.height, achieved_hz)), Some((final_dims.0, final_dims.1, achieved_hz)),
Box::new(StopGuard { stop }), Box::new(StopGuard { stop }),
); );
out.expect_exact_dims = expect_exact_dims; out.expect_exact_dims = expect_exact_dims;
@@ -415,16 +433,116 @@ fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> {
)) ))
} }
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created /// CVT's horizontal cell granularity. KWin generates every custom mode's timing with **libxcvt**,
/// virtual output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or /// whose first step is `hdisplay_rnd = hdisplay - (hdisplay % 8)` — so a width that isn't a multiple
/// name, see [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode** /// of 8 comes back NARROWER than asked, and the clock-step rounding that follows lands a fractional
/// and return `(achieved_hz, size_applied)`. The apply command can report success yet leave the /// refresh. A 2868x1320@120 request (an iPhone 16 Pro Max panel) becomes **2864x1320@119.92**.
/// output on its old mode (rejected), and a silent rate mismatch surfaces downstream as judder / ///
/// duplicated frames — so the caller paces the encoder to the *achieved* rate, not the requested /// That is why a custom mode must never be selected by the `WxH@Hz` string we *requested*:
/// one. `size_applied` tells the sacrificial-birth caller (see `create`) whether the SIZE half of /// kscreen-doctor's `findMode` matches a mode's id or its own `WxH@qRound(Hz)` name, so
/// the mode actually landed — that, not the refresh, is what triggers KWin's stream /// `2868x1320@120` matches nothing, the select silently no-ops, the output stays on its sacrificial
/// renegotiation. /// birth mode, and the caller falls back to 60 Hz — while KDE's display list shows the perfectly
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, bool) { /// good 2864x1320@119.92 mode sitting there unselected. Widths like 1920/2560/3840 are all
/// multiples of 8, which is why only phone-shaped clients ever hit it.
const CVT_H_GRANULARITY: u32 = 8;
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
#[derive(Clone, Debug, PartialEq)]
struct KModeRow {
/// kscreen's mode id — what we address the mode by (never the requested `WxH@Hz` string).
id: String,
w: u32,
h: u32,
hz: f64,
}
/// A kscreen JSON id, which is a string on some KWin versions and a number on others.
fn json_id(v: &serde_json::Value) -> Option<String> {
v.as_str()
.map(|s| s.to_string())
.or_else(|| v.as_u64().map(|n| n.to_string()))
}
/// The full mode list of `output` (a RESOLVED kscreen address — numeric id or name) from a parsed
/// `kscreen-doctor -j` document. Split from the process call so the picker can be tested on
/// captured JSON.
fn modes_from_json(doc: &serde_json::Value, output: &str) -> Vec<KModeRow> {
let Some(o) = doc
.get("outputs")
.and_then(|v| v.as_array())
.and_then(|outs| {
outs.iter().find(|o| {
o.get("name").and_then(|n| n.as_str()) == Some(output)
|| o.get("id").and_then(json_id).as_deref() == Some(output)
})
})
else {
return Vec::new();
};
o.get("modes")
.and_then(|m| m.as_array())
.map(|ms| {
ms.iter()
.filter_map(|m| {
let size = m.get("size")?;
Some(KModeRow {
id: m.get("id").and_then(json_id)?,
w: size.get("width").and_then(|v| v.as_u64())? as u32,
h: size.get("height").and_then(|v| v.as_u64())? as u32,
hz: m.get("refreshRate").and_then(|r| r.as_f64())?,
})
})
.collect()
})
.unwrap_or_default()
}
/// [`modes_from_json`] against a live `kscreen-doctor -j`.
fn output_modes(output: &str) -> Vec<KModeRow> {
kscreen_json()
.map(|doc| modes_from_json(&doc, output))
.unwrap_or_default()
}
/// The mode in `modes` that actually fulfils a `width`x`height`@`hz` request, tolerating the CVT
/// alignment KWin applies when it generates the timing (see [`CVT_H_GRANULARITY`]): the height must
/// match exactly (CVT never touches the vertical active), the width may be up to one cell narrower
/// than asked (never wider — that would be a different mode), and the refresh must land within 1 Hz
/// of the request (which excludes the output's native 60 Hz entry for every rate we install a custom
/// mode for). Widest wins, then fastest — so an exact-width mode always beats an aligned one, and a
/// list carrying duplicate custom modes from earlier sessions still resolves.
fn pick_custom_mode<'a>(
modes: &'a [KModeRow],
width: u32,
height: u32,
hz: u32,
) -> Option<&'a KModeRow> {
modes
.iter()
.filter(|m| {
m.h == height
&& m.w <= width
&& width - m.w < CVT_H_GRANULARITY
&& (m.hz - f64::from(hz)).abs() < 1.0
})
.max_by(|a, b| {
a.w.cmp(&b.w)
.then(a.hz.partial_cmp(&b.hz).unwrap_or(std::cmp::Ordering::Equal))
})
}
/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created virtual
/// output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or name, see
/// [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode** and return
/// it as `(width, height, refresh_hz)`. `None` if the read-back failed entirely.
///
/// The apply command can report success yet leave the output on its old mode (rejected), and a
/// silent size/rate mismatch surfaces downstream as a starved capture gate or judder — so the
/// caller drives the pipeline off the *achieved* mode, not the requested one. The mode is selected
/// by kscreen **mode id** resolved from the output's own list, never by the requested `WxH@Hz`
/// string, because KWin's CVT generator may hand back a slightly different one
/// ([`CVT_H_GRANULARITY`]).
fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> Option<(u32, u32, u32)> {
let output = output.to_string(); let output = output.to_string();
let mhz = hz.saturating_mul(1000); let mhz = hz.saturating_mul(1000);
let run = |arg: String| { let run = |arg: String| {
@@ -434,21 +552,71 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
.map(|s| s.success()) .map(|s| s.success())
.unwrap_or(false) .unwrap_or(false)
}; };
// Add the custom mode (a fresh output has none), then select it. // Install the mode only if the output doesn't already carry a usable one: kscreen-doctor
let _ = run(format!( // APPENDS to the output's custom-mode list and KWin PERSISTS that list per output name
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full" // (`kwinoutputconfig.json`, which is why the same per-slot name is reused across sessions) — so
)); // re-adding on every connect would grow the user's display list without bound.
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}")); let mut modes = output_modes(&output);
if pick_custom_mode(&modes, width, height, hz).is_none() {
let _ = run(format!(
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
));
modes = output_modes(&output);
}
let applied = match pick_custom_mode(&modes, width, height, hz) {
Some(target) => {
if (target.w, target.h) != (width, height) {
tracing::info!(
output,
requested_w = width,
requested_h = height,
mode_w = target.w,
mode_h = target.h,
mode_hz = target.hz,
"KWin aligned the custom mode to the CVT cell grain — streaming at its size"
);
}
// By id first; the human `WxH@Hz` form (built from the mode's OWN size/refresh, not the
// request) is the fallback for builds whose ids don't round-trip through the CLI.
run(format!("output.{output}.mode.{}", target.id))
|| run(format!(
"output.{output}.mode.{}x{}@{}",
target.w,
target.h,
target.hz.round() as u32
))
}
None => {
tracing::warn!(
output,
requested_w = width,
requested_h = height,
requested_hz = hz,
offered = ?modes,
"KWin offers no mode matching the request after addCustomMode — is kscreen-doctor \
up to date, and KWin ≥ 6.6 (custom modes on virtual outputs)?"
);
false
}
};
match read_active_mode(&output) { match read_active_mode(&output) {
Some((w, h, achieved)) => { Some((w, h, achieved)) => {
let size_applied = (w, h) == (width, height); if achieved >= hz && (w, h) == (width, height) {
if achieved >= hz && size_applied {
tracing::info!( tracing::info!(
output, output,
requested = hz, requested = hz,
achieved, achieved,
"KWin virtual output: custom refresh applied" "KWin virtual output: custom refresh applied"
); );
} else if achieved >= hz {
tracing::info!(
output,
requested = hz,
achieved,
active_w = w,
active_h = h,
"KWin virtual output: custom refresh applied at a CVT-aligned size"
);
} else { } else {
tracing::warn!( tracing::warn!(
output, output,
@@ -461,7 +629,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)" achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)"
); );
} }
(achieved.max(1), size_applied) Some((w, h, achieved.max(1)))
} }
None => { None => {
tracing::warn!( tracing::warn!(
@@ -471,7 +639,7 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, b
"could not read back KWin virtual output refresh — assuming 60 Hz (is \ "could not read back KWin virtual output refresh — assuming 60 Hz (is \
kscreen-doctor installed?)" kscreen-doctor installed?)"
); );
(60, false) None
} }
} }
} }
@@ -884,7 +1052,88 @@ fn run(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::MANAGED_PREFIX; use super::{modes_from_json, pick_custom_mode, KModeRow, MANAGED_PREFIX};
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
KModeRow {
id: id.to_string(),
w,
h,
hz,
}
}
/// The reported regression: an iPhone 16 Pro Max asks for 2868x1320@120; libxcvt rounds the
/// width down to the 8-pixel cell grain and the clock step lands 119.92, so KWin's list holds
/// 2864x1320@119.92. Selecting by the REQUESTED `2868x1320@120` string matched nothing — the
/// output stayed on its birth mode and the session fell back to 60 Hz. The picker must find it.
#[test]
fn picks_the_cvt_aligned_mode() {
let modes = [
row("1", 2868, 1320, 60.0), // the virtual output's native/birth mode
row("2", 2864, 1320, 119.92), // the custom mode KWin actually generated
];
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("CVT-aligned mode");
assert_eq!((got.id.as_str(), got.w, got.h), ("2", 2864, 1320));
}
/// A width already on the cell grain (every PC resolution: 1920/2560/3840) round-trips exactly,
/// and an exact-width mode outranks an aligned one when both are offered.
#[test]
fn exact_width_outranks_an_aligned_one() {
let modes = [
row("1", 2560, 1440, 60.0),
row("2", 2552, 1440, 119.93), // a stale narrower custom mode from an earlier session
row("3", 2560, 1440, 119.98),
];
let got = pick_custom_mode(&modes, 2560, 1440, 120).expect("exact mode");
assert_eq!(got.id, "3");
}
/// The picker must never wander onto an unrelated mode: not the 60 Hz native entry (the old
/// fallback the reporter got stuck on), not a different height, not a wider width, and not a
/// mode more than one cell narrower than asked.
#[test]
fn rejects_modes_that_are_not_the_request() {
let modes = [
row("1", 2868, 1320, 60.0), // native — refresh too far off
row("2", 2868, 1080, 119.92), // wrong height
row("3", 2880, 1320, 119.92), // wider than requested
row("4", 2856, 1320, 119.92), // two cells narrower — not a CVT alignment of 2868
row("5", 1920, 1080, 120.0), // unrelated
];
assert!(pick_custom_mode(&modes, 2868, 1320, 120).is_none());
}
/// Mode + output ids come through as JSON strings on some KWin versions and numbers on others;
/// both must parse, and a mode row missing its size/refresh is skipped rather than poisoning
/// the list.
#[test]
fn parses_both_id_encodings() {
let doc: serde_json::Value = serde_json::from_str(
r#"{"outputs":[
{"id":7,"name":"Virtual-punktfunk","modes":[
{"id":"m1","size":{"width":2868,"height":1320},"refreshRate":60.0},
{"id":42,"size":{"width":2864,"height":1320},"refreshRate":119.92},
{"id":"broken","size":{"width":800}}
]},
{"id":1,"name":"eDP-1","modes":[
{"id":"x","size":{"width":2864,"height":1320},"refreshRate":119.92}
]}
]}"#,
)
.expect("fixture parses");
// Addressable by numeric id (how `resolve_kscreen_addr` returns it) and by name.
for addr in ["7", "Virtual-punktfunk"] {
let modes = modes_from_json(&doc, addr);
assert_eq!(modes.len(), 2, "the malformed row is dropped ({addr})");
assert_eq!(modes[1].id, "42", "numeric mode ids stringify ({addr})");
let got = pick_custom_mode(&modes, 2868, 1320, 120).expect("aligned mode");
assert_eq!(got.id, "42");
}
// Never reads another output's list (the eDP-1 entry carries a matching mode).
assert!(modes_from_json(&doc, "Virtual-nope").is_empty());
}
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled, /// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output /// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output