fix(pf-vdisplay): verify the size KWin actually built the virtual output at (≤60 Hz path) #194

Merged
enricobuehler merged 1 commits from worktree-moonlight-4k60-dims into main 2026-08-13 13:52:32 +00:00
3 changed files with 237 additions and 7 deletions
+177 -6
View File
@@ -302,10 +302,14 @@ impl VirtualDisplay for KwinDisplay {
let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
// `requested_*`, NOT `width`/`height`: `spawn_vout` hands back a node id, never a size, so
// every number on this line is what we ASKED for. Logged as `width=… height=…` it read like
// a readback of what KWin built, and a field report where KWin had actually built a 1080p
// output was diagnosed against a log line stating 3840x2160. The readback is below.
tracing::info!(
node_id,
width,
height,
requested_w = width,
requested_h = height,
birth_h,
embedded_pointer = !self.hw_cursor,
"KWin virtual output ready"
@@ -346,9 +350,7 @@ impl VirtualDisplay for KwinDisplay {
// width at or just below the request (a CVT alignment). That also proves the output
// left the sacrificial birth size, so the recording stream will renegotiate to it.
match active {
Some((aw, ah, ahz))
if ah == height && aw <= width && width - aw < CVT_H_GRANULARITY =>
{
Some((aw, ah, ahz)) if mode_satisfies((aw, ah), width, height) => {
expect_exact_dims = true;
final_dims = (aw, ah);
ahz
@@ -381,6 +383,125 @@ impl VirtualDisplay for KwinDisplay {
}
}
} else {
// ≤60 Hz installs no mode, so nothing here ever learned what KWin actually built — and
// KWin does not necessarily build what it was asked for. `OutputConfigurationStore`
// restores per-output mode AND scale from `kwinoutputconfig.json` keyed by output NAME,
// and ours is stable across sessions by design (Stage 3, so KDE reapplies that client's
// scaling) — so a slot that last ran at 1080p gets 1080p put back on top of the 4K we
// just requested. The >60 Hz arm above is immune only incidentally: it installs a mode,
// so it gets a readback for free.
//
// Unverified, that mismatch is silent and total. The capture builds at KWin's size, the
// encoder opens against it, and Moonlight — which configured its decoder for the size it
// negotiated over RTSP — receives a bitstream it cannot decode, asks for a keyframe
// every ~50 ms, and drops the session. Meanwhile every dims-keyed resolve below
// (`apply_topology`, `clear_replication_source`, `resolve_kscreen_addr`) is looking for
// an output at the requested size and quietly finding nothing, so the stream isn't even
// made primary or de-mirrored.
match crate::kwin_output_mgmt::actual_dims(&our_prefix) {
// KWin honoured the request — the overwhelmingly common case. No configuration is
// built and nothing is applied: byte-for-byte the behaviour this arm always had.
//
// The scale is recorded rather than corrected. A non-1.0 scale here is NOT a fault
// to repair: the stable output name exists precisely so KDE reapplies this client's
// scaling on reconnect (Stage 3), so forcing the 1.0 we asked `stream_virtual_output`
// for would undo a feature. It is logged because it is the other half of the stored
// per-output config, and because the pixel-vs-logical question it raises is exactly
// what a future "the size is right but the capture is halved" report will turn on —
// KWin's output screencast streams the source's PIXEL size, so a scale should not
// move the captured dimensions, and a report showing otherwise would be the evidence
// that assumption is wrong on some KWin version.
Some((aw, ah, _, scale)) if (aw, ah) == (width, height) => {
if scale != 1.0 {
tracing::debug!(
width,
height,
scale,
"KWin virtual output verified at the requested size, carrying a stored \
non-unity scale (per-client scaling — capture is unaffected)"
);
}
}
Some((aw, ah, _, scale)) => {
tracing::warn!(
actual_w = aw,
actual_h = ah,
requested_w = width,
requested_h = height,
stored_scale = scale,
our_prefix,
"KWin built our virtual output at a DIFFERENT size than requested (a stored \
kwinoutputconfig.json mode/scale for this output name) — re-asserting the \
requested mode so the stream matches what the client negotiated"
);
// Re-assert the requested size through the SAME install+select the sacrificial
// birth uses above: an output sitting at a size we don't want, moved to the one
// we do, with the screencast stream renegotiating to it on the first buffers
// recorded after the consumer connects. `aw`/`ah` play the birth size — that is
// literally what they are here, just not deliberately.
//
// 60 Hz, NOT `mode.refresh_hz`: this arm is ≤60 Hz by construction and only the
// SIZE is wrong. Asking for the client's rate would install a 30 Hz mode for a
// 30 fps client and throttle the compositor to it — a behaviour change fixing a
// size has no business making. KWin's virtual outputs are 60 Hz natively and
// `achieved_hz` below stays the client's rate exactly as before.
match crate::kwin_output_mgmt::set_custom_mode(
&our_prefix,
aw,
ah,
width,
height,
60,
) {
// Same acceptance test as the high-refresh arm — literally, so the two can
// never drift. That the mode moved at all also proves the screencast will
// renegotiate, which is what `expect_exact_dims` then waits for.
Some((cw, ch, _)) if mode_satisfies((cw, ch), width, height) => {
expect_exact_dims = true;
final_dims = (cw, ch);
tracing::info!(
active_w = cw,
active_h = ch,
"KWin virtual output corrected to the requested size"
);
}
other => {
// Correction refused (pre-6.6 KWin has no `set_custom_modes`, or the
// compositor didn't answer). Report the size that is REALLY there, not
// the one we asked for: the dims-keyed resolves below and the encoder
// all key on `final_dims`, and carrying the request forward is what
// made this a silent failure rather than a degraded one. The session
// still runs, at KWin's size: the stream layer warns that the client is
// decoding something other than what it negotiated but does NOT refuse
// it, because a monitor mirror legitimately streams a size the client
// never asked for (§7.3) and failing here would break every one.
tracing::warn!(
active = ?other,
actual_w = aw,
actual_h = ah,
requested_w = width,
requested_h = height,
"KWin would not re-assert the requested mode — the output is STUCK \
at its stored size. Clear this output's entry from \
kwinoutputconfig.json (or set it to the streamed resolution in \
System Settings → Display) and reconnect"
);
final_dims = (aw, ah);
}
}
}
// Management unavailable, or two outputs share our name (a supersede in flight, the
// one case only a dims-keyed resolve can disambiguate). Nothing verifiable to act
// on, so carry on exactly as this arm always did rather than reconfigure an output
// we cannot identify.
None => {
tracing::debug!(
our_prefix,
"KWin: could not read back the virtual output's actual mode (management \
unavailable or a same-named supersede in flight) — proceeding unverified"
);
}
}
mode.refresh_hz
};
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
@@ -733,6 +854,25 @@ fn monitors_from_kscreen_json(doc: &serde_json::Value) -> Vec<crate::monitors::P
/// compiler was checking.
pub(crate) const CVT_H_GRANULARITY: u32 = 8;
/// Does the mode that actually went ACTIVE satisfy a request for `want_w`×`want_h`?
///
/// Exact height, and a width at or just below the request — never an exact width, because KWin
/// generates custom timings through libxcvt and that rounds the width DOWN to the cell grain
/// ([`CVT_H_GRANULARITY`]). Demanding an exact width would reject the very mode we just asked KWin
/// to build, for phone-shaped clients (see the constant's note).
///
/// Both arms of [`VirtualDisplay::create`] that put a mode on the output test their readback
/// through here — the sacrificial high-refresh birth, and the correction for a size KWin restored
/// from its stored per-output config. They are the same question and they were, briefly, two copies
/// of the same expression; one place to change it is the point.
///
/// A width ABOVE the request fails: `aw <= want_w` guards the subtraction on the next line, and a
/// mode wider than we asked for is not a CVT alignment of our request — it is somebody else's mode.
fn mode_satisfies(active: (u32, u32), want_w: u32, want_h: u32) -> bool {
let (aw, ah) = active;
ah == want_h && aw <= want_w && want_w - aw < CVT_H_GRANULARITY
}
/// One row of an output's mode list, as parsed from `kscreen-doctor -j`.
#[derive(Clone, Debug, PartialEq)]
struct KModeRow {
@@ -1851,9 +1991,40 @@ fn await_created(
#[cfg(test)]
mod tests {
use super::{
modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow, MANAGED_PREFIX,
mode_satisfies, modes_from_json, monitors_from_kscreen_json, pick_custom_mode, KModeRow,
MANAGED_PREFIX,
};
/// The field failure this predicate now guards, in the shape the log reported it: a client
/// negotiated 3840x2160, KWin restored a stored 1920x1080 for the output name, and nothing
/// compared the two — so the session captured 1080p, encoded 1080p, and shipped it to a client
/// that had configured its decoder for 4K. Half the requested size is not an alignment.
#[test]
fn a_restored_stored_mode_does_not_pass_for_the_requested_one() {
assert!(!mode_satisfies((1920, 1080), 3840, 2160));
}
/// The case the predicate must NOT reject, and the reason it can't just test equality: libxcvt
/// rounds a width down to the 8-px cell grain, so the mode KWin builds for a 2868-wide request
/// really is 2864 wide. Rejecting it would strand the output on its birth mode.
#[test]
fn a_cvt_aligned_width_still_satisfies_the_request() {
assert!(mode_satisfies((2864, 1320), 2868, 1320));
assert!(mode_satisfies((3840, 2160), 3840, 2160)); // exact is the common case
}
/// The alignment slack is bounded and one-sided. A width 8+ px short is a different mode, not a
/// rounding of ours; a width ABOVE the request is somebody else's mode entirely (and is what
/// would underflow the subtraction if the `<=` guard were ever dropped); and the height is
/// never rounded, so it must match exactly.
#[test]
fn the_alignment_slack_is_bounded_one_sided_and_width_only() {
assert!(mode_satisfies((3833, 2160), 3840, 2160)); // 7 short — inside the grain
assert!(!mode_satisfies((3832, 2160), 3840, 2160)); // 8 short — a different mode
assert!(!mode_satisfies((3848, 2160), 3840, 2160)); // wider than asked
assert!(!mode_satisfies((3840, 2159), 3840, 2160)); // height is never aligned
}
fn row(id: &str, w: u32, h: u32, hz: f64) -> KModeRow {
KModeRow {
id: id.to_string(),
@@ -1030,6 +1030,44 @@ pub(crate) fn clear_replication_source(our_prefix: &str, our_w: u32, our_h: u32)
}
}
/// The size + scale our just-created virtual output ACTUALLY landed at, read only.
///
/// [`resolve_ours`] keys on the size we asked KWin for, which answers "is our output there?" but
/// can never answer "did KWin give us what we asked for?" — a miss is indistinguishable from an
/// output that simply hasn't appeared. That gap is not theoretical: KWin restores per-output config
/// (mode AND scale) from `kwinoutputconfig.json` keyed by output NAME, and ours is deliberately
/// stable across sessions (see the note on [`is_mirroring`]), so a stored 1080p mode left by an
/// earlier session is re-applied on top of the 4K we just requested. Every dims-keyed caller then
/// silently misses — topology, de-mirror, position — and the capture pipeline builds at a size the
/// client never negotiated.
///
/// Resolution is by NAME ALONE, so it deliberately declines (`None`) unless EXACTLY ONE output
/// carries our prefix. Two matches means a supersede is in flight, and the dims filter is the only
/// thing that can tell the replacement from the predecessor it reuses the name of — picking wrong
/// here would hand the caller the doomed output's size and, worse, invite it to reconfigure the
/// output that is about to disappear. Failing closed leaves today's behaviour untouched; the
/// verification is an addition, never a new way to get it wrong. (A prefix that is also a prefix of
/// a sibling slot's name — `-7` vs `-70` — reads as ambiguous and declines for the same reason.)
///
/// Returns `(width, height, refresh_mHz, scale)`. Scale is reported for the log rather than acted
/// on: KWin's output screencast streams the source's PIXEL size, so a restored scale shifts the
/// desktop's logical layout without changing what we capture — but it is the other half of the
/// stored config, and naming it in the log is what turns "why is this 1080p" into one glance.
pub(crate) fn actual_dims(our_prefix: &str) -> Option<(u32, u32, u32, f64)> {
let sess = Session::open("verify_dims").ok()?;
let mut matches = sess.state.devices.values().filter(|d| {
// `seen_done`: a device mid-announce has no coherent current_mode to read, and reading one
// anyway is how you get a "KWin gave us 0x0" correction that stomps a healthy output.
d.seen_done && d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
});
let ours = matches.next()?;
if matches.next().is_some() {
return None;
}
let (w, h, mhz) = sess.current_dims(ours)?;
Some((w, h, mhz, ours.scale.filter(|s| *s > 0.0).unwrap_or(1.0)))
}
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
+22 -1
View File
@@ -1095,10 +1095,31 @@ fn stream_body(
// The first frame establishes the authoritative size/format for the encoder.
let mut frame = capturer.next_frame().context("capture first frame")?;
if frame.width != cfg.width || frame.height != cfg.height {
// Deliberately NOT fatal, and deliberately not "resize the output" (which read as an
// instruction nothing was carrying out). A mismatch has two very different causes and only
// one of them is a fault:
//
// * Mirroring a pinned monitor — EXPECTED. §7.3: a panel runs at the mode its owner set
// and the client scales, so `open_gs_mirror_source` passes the client's mode purely to
// keep the argument honest and the backend ignores it. Failing here would break every
// mirror session.
// * A virtual display — a FAULT. That output was created at the client's negotiated size
// precisely so this can't happen, so a mismatch means the backend did not build what it
// was asked for. The KWin backend now reads its output back and re-asserts the mode, so
// look for its `KWin built our virtual output at a DIFFERENT size` / `would not
// re-assert` lines just above this one — they carry the cause and the remedy.
//
// What the client does with it is the part worth stating plainly: the encoder opens at the
// CAPTURED size below, so Moonlight receives a bitstream that disagrees with the resolution
// it configured its decoder from. Tolerant decoders re-init off the SPS and scale; strict
// ones (Media Foundation on Xbox) may instead produce nothing and ask for a keyframe every
// ~50 ms until the client gives up and drops the session.
tracing::warn!(
captured = ?(frame.width, frame.height),
negotiated = ?(cfg.width, cfg.height),
"captured size != negotiated size — Moonlight expects the negotiated size; resize the output"
"captured size != negotiated size — the client decodes a stream that disagrees with \
what it negotiated (expected when mirroring a monitor; a virtual-display backend fault \
otherwise see the vdisplay lines above)"
);
}
let mut enc = encode::open_video(