fix(host): activate lid-closed pf-vdisplay targets — explicit CCD path-commit fallback
ci / docs-site (push) Successful in 1m3s
ci / web (push) Successful in 1m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 5m2s
ci / bench (push) Successful in 8m3s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 9m3s
arch / build-publish (push) Successful in 14m56s
android / android (push) Successful in 16m48s
ci / rust (push) Successful in 18m17s
deb / build-publish (push) Successful in 18m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m32s
apple / screenshots (push) Successful in 20m40s
ci / docs-site (push) Successful in 1m3s
ci / web (push) Successful in 1m3s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
decky / build-publish (push) Successful in 17s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
apple / swift (push) Successful in 5m2s
ci / bench (push) Successful in 8m3s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 9m3s
arch / build-publish (push) Successful in 14m56s
android / android (push) Successful in 16m48s
ci / rust (push) Successful in 18m17s
deb / build-publish (push) Successful in 18m31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m32s
apple / screenshots (push) Successful in 20m40s
A lid-closed laptop defeats both activation stages for a fresh IddCx target: the clamshell lid policy suppresses the new-monitor auto-activate, and the SDC_TOPOLOGY_EXTEND preset returns success without committing a path for the IDD — so every session retry burned ~10s in resolve_target_gdi and the stream died with "not yet an active display path" after 8 attempts (RDP/Parsec still work there: neither needs a NEW console display path). Field report: Windows laptop host, Intel iGPU, lid closed, v0.10.1. New activate_target_path() (win_display.rs) is the supplied-config apply Windows' own display Settings uses to turn a monitor on, which doesn't consult the lid policy: QueryDisplayConfig(QDC_ALL_PATHS), keep every active path verbatim, append the target's inactive path with a source no active display is using (never a clone), both mode idxs DISPLAYCONFIG_PATH_MODE_IDX_INVALID, then SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE — SAVE_TO_DATABASE so the next same-identity ADD auto-activates from the persistence DB and skips the ladder. Wired as the THIRD stage of resolve_target_gdi; the on-glass-validated auto-activate → force-EXTEND order is unchanged. Also sweep stale "SudoVDA" out of logs/errors and current-behavior doc comments (the backend was removed; pf-vdisplay is the sole one): the capture error now names pf-vdisplay, the HDR toggle logs virtual-display, and the not-active warns list the exhausted fallbacks. Genuinely historical SudoVDA notes stay. cargo check + clippy green on the Windows box; on-glass lid-closed repro still owed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -405,7 +405,8 @@ pub fn capture_virtual_output(
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
let target = vout.win_capture.clone().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"SudoVDA target not yet an active display (needs a WDDM GPU to activate it)"
|
||||
"pf-vdisplay target not yet an active display path (activation failed — see the \
|
||||
virtual-display warnings above)"
|
||||
)
|
||||
})?;
|
||||
let pref = vout.preferred_mode;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub struct WinCaptureTarget {
|
||||
pub adapter_luid: i64,
|
||||
/// The output's GDI device name, e.g. `\\.\DISPLAY3`. Can CHANGE across a secure-desktop switch.
|
||||
pub gdi_name: String,
|
||||
/// Stable SudoVDA target id — re-resolved to the current GDI name on every recovery.
|
||||
/// Stable virtual-display (IddCx) target id — re-resolved to the current GDI name on every recovery.
|
||||
pub target_id: u32,
|
||||
/// The pf-vdisplay driver's WUDFHost pid (from the ADD reply) — the process the IDD-push capturer
|
||||
/// duplicates the sealed frame channel's handles INTO (`idd_push::ChannelBroker`). `0` = unknown
|
||||
|
||||
@@ -3122,7 +3122,7 @@ fn resolve_compositor(
|
||||
dedicated_launch: bool,
|
||||
) -> Result<crate::vdisplay::Compositor> {
|
||||
use crate::vdisplay::Compositor;
|
||||
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
|
||||
// Windows has a single virtual-display backend (pf-vdisplay); vdisplay::open ignores the compositor
|
||||
// arg there, so short-circuit the Linux session-detection state machine with a placeholder.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
|
||||
@@ -61,7 +61,7 @@ pub struct VirtualOutput {
|
||||
/// gamescope outputs are created at the exact size, so this just confirms it; **Mutter sizes
|
||||
/// its virtual monitor FROM the negotiation**, so here it's what makes the client's mode real.
|
||||
pub preferred_mode: Option<(u32, u32, u32)>,
|
||||
/// Windows capture identity (DXGI adapter LUID + GDI output name) for the SudoVDA backend —
|
||||
/// Windows capture identity (DXGI adapter LUID + GDI output name) for the pf-vdisplay backend —
|
||||
/// what [`crate::capture::capture_virtual_output`] needs to duplicate the right output.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
|
||||
|
||||
@@ -822,6 +822,13 @@ impl VirtualDisplayManager {
|
||||
/// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared
|
||||
/// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first.
|
||||
///
|
||||
/// LAST RESORT — explicit path activation: a lid-closed laptop (field report, Intel iGPU) defeats
|
||||
/// BOTH stages above — the clamshell lid policy suppresses the new-monitor auto-activation, and
|
||||
/// the `SDC_TOPOLOGY_EXTEND` preset "succeeds" without committing a path for the IDD — so the
|
||||
/// target stays connected-but-inactive for the session's whole retry budget. `activate_target_path`
|
||||
/// commits the target's path directly (supplied-config apply, the same thing display Settings
|
||||
/// does), which doesn't consult the lid policy at all.
|
||||
///
|
||||
/// # Safety
|
||||
/// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock.
|
||||
unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option<String> {
|
||||
@@ -842,6 +849,17 @@ impl VirtualDisplayManager {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
// SAFETY: `activate_target_path` runs the CCD query/apply FFI with owned local buffers; the
|
||||
// `Copy` target id is passed by value, under the `state` lock — the sole topology mutator.
|
||||
if unsafe { crate::win_display::activate_target_path(target_id) } {
|
||||
for _ in 0..15 {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
// SAFETY: as the resolve loops above.
|
||||
if let Some(n) = unsafe { resolve_gdi_name(target_id) } {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -886,7 +904,11 @@ impl VirtualDisplayManager {
|
||||
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
|
||||
tracing::info!(
|
||||
backend = self.driver.name(),
|
||||
"target {} -> {n}",
|
||||
added.target_id
|
||||
);
|
||||
// ADD only advertises the mode; force it active so DXGI captures the requested size.
|
||||
set_active_mode(n, mode);
|
||||
// Apply the display-management topology (Stage 2, GROUP-scoped since Stage W2).
|
||||
@@ -977,8 +999,7 @@ impl VirtualDisplayManager {
|
||||
}
|
||||
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
|
||||
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
|
||||
inner.group.ccd_saved =
|
||||
unsafe { set_virtual_primary_ccd(added.target_id) };
|
||||
inner.group.ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
|
||||
}
|
||||
Topology::Primary => {
|
||||
// A sibling already holds primary (the group's designated member) — the new
|
||||
@@ -996,7 +1017,8 @@ impl VirtualDisplayManager {
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"virtual-display target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
"virtual-display target {} not yet an active display path (auto-activate, EXTEND \
|
||||
preset and explicit path activation all failed — GPU-less box?)",
|
||||
added.target_id
|
||||
),
|
||||
}
|
||||
@@ -1081,7 +1103,11 @@ impl VirtualDisplayManager {
|
||||
let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) };
|
||||
match &gdi_name {
|
||||
Some(n) => {
|
||||
tracing::info!(backend = self.driver.name(), "re-arrival target {} -> {n}", added.target_id);
|
||||
tracing::info!(
|
||||
backend = self.driver.name(),
|
||||
"re-arrival target {} -> {n}",
|
||||
added.target_id
|
||||
);
|
||||
// ADD only advertises the mode; force it active so DXGI/IDD captures the new size.
|
||||
set_active_mode(n, mode);
|
||||
// 4. Re-isolate the composited set with the NEW target replacing the old — preserving
|
||||
@@ -1091,7 +1117,8 @@ impl VirtualDisplayManager {
|
||||
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture reopens
|
||||
}
|
||||
None => tracing::warn!(
|
||||
"re-arrival target {} not yet an active display path (needs a WDDM GPU to activate)",
|
||||
"re-arrival target {} not yet an active display path (auto-activate, EXTEND preset \
|
||||
and explicit path activation all failed — GPU-less box?)",
|
||||
added.target_id
|
||||
),
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ use windows::Win32::Devices::Display::{
|
||||
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
|
||||
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
|
||||
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
|
||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
|
||||
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME, QDC_ALL_PATHS,
|
||||
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
|
||||
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
|
||||
};
|
||||
@@ -56,7 +56,107 @@ pub(crate) unsafe fn force_extend_topology() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the `\\.\DisplayN` GDI name for a SudoVDA target id via the CCD API. Returns `None`
|
||||
/// EXPLICITLY activate `target_id` into its own display path — the last-resort fallback when neither
|
||||
/// the OS auto-activate nor the EXTEND topology preset lights a freshly-ADDed IDD target. Observed on
|
||||
/// a lid-closed laptop (field report, Intel iGPU): the clamshell lid policy makes Windows skip the
|
||||
/// new-monitor auto-activation AND the `SDC_TOPOLOGY_EXTEND` preset returns success without ever
|
||||
/// committing a path for the IDD, so the target sits connected-but-inactive for the whole retry
|
||||
/// budget (RDP/Parsec don't need a new console display path, which is why they still work there).
|
||||
///
|
||||
/// This is the supplied-config apply Windows' own display Settings uses to turn a monitor on: query
|
||||
/// ALL paths, keep every currently-active path verbatim, and append the target's inactive path with a
|
||||
/// source not already driving another display — mode indices invalidated so `SDC_ALLOW_CHANGES` lets
|
||||
/// the OS pick modes for the new path. Returns `true` when the apply reports success; the caller
|
||||
/// still re-polls [`resolve_gdi_name`] to confirm the path actually committed.
|
||||
pub(crate) unsafe fn activate_target_path(target_id: u32) -> bool {
|
||||
let mut np = 0u32;
|
||||
let mut nm = 0u32;
|
||||
if GetDisplayConfigBufferSizes(QDC_ALL_PATHS, &mut np, &mut nm).is_err() {
|
||||
return false;
|
||||
}
|
||||
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
|
||||
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
|
||||
if QueryDisplayConfig(
|
||||
QDC_ALL_PATHS,
|
||||
&mut np,
|
||||
paths.as_mut_ptr(),
|
||||
&mut nm,
|
||||
modes.as_mut_ptr(),
|
||||
None,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
paths.truncate(np as usize);
|
||||
modes.truncate(nm as usize);
|
||||
|
||||
// Keep the currently-active paths verbatim — their mode indices stay valid because the queried
|
||||
// modes array is passed through unchanged.
|
||||
let mut supplied: Vec<DISPLAYCONFIG_PATH_INFO> = paths
|
||||
.iter()
|
||||
.filter(|p| p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0)
|
||||
.copied()
|
||||
.collect();
|
||||
if supplied.iter().any(|p| p.targetInfo.id == target_id) {
|
||||
return true; // already active — we raced the OS auto-activate
|
||||
}
|
||||
|
||||
// Pick an inactive path for our target whose SOURCE isn't already driving an active display on
|
||||
// the same adapter (sharing one would make the IDD a clone — exactly the no-frames state this
|
||||
// fallback exists to break out of).
|
||||
let Some(cand) = paths.iter().find(|p| {
|
||||
p.targetInfo.id == target_id
|
||||
&& p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0
|
||||
&& !supplied.iter().any(|a| {
|
||||
(
|
||||
a.sourceInfo.adapterId.LowPart,
|
||||
a.sourceInfo.adapterId.HighPart,
|
||||
a.sourceInfo.id,
|
||||
) == (
|
||||
p.sourceInfo.adapterId.LowPart,
|
||||
p.sourceInfo.adapterId.HighPart,
|
||||
p.sourceInfo.id,
|
||||
)
|
||||
})
|
||||
}) else {
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
"explicit path activation: no inactive path with a free source for this target"
|
||||
);
|
||||
return false;
|
||||
};
|
||||
let mut new_path = *cand;
|
||||
new_path.flags |= DISPLAYCONFIG_PATH_ACTIVE;
|
||||
new_path.sourceInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
new_path.targetInfo.Anonymous.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID;
|
||||
supplied.push(new_path);
|
||||
|
||||
// SAVE_TO_DATABASE so Windows remembers the arrangement — the next same-identity ADD (the driver
|
||||
// reuses the slot's EDID serial/ConnectorIndex) then auto-activates from the persistence DB and
|
||||
// skips this whole fallback ladder.
|
||||
let rc = SetDisplayConfig(
|
||||
Some(supplied.as_slice()),
|
||||
Some(modes.as_slice()),
|
||||
SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_ALLOW_CHANGES | SDC_SAVE_TO_DATABASE,
|
||||
);
|
||||
if rc == 0 {
|
||||
tracing::info!(
|
||||
target_id,
|
||||
"explicit path activation: supplied-config apply succeeded (target committed alongside {} active path(s))",
|
||||
supplied.len() - 1
|
||||
);
|
||||
true
|
||||
} else {
|
||||
tracing::warn!(
|
||||
target_id,
|
||||
"explicit path activation: SetDisplayConfig rc={rc:#x}"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the `\\.\DisplayN` GDI name for a virtual-display target id via the CCD API. Returns `None`
|
||||
/// until the OS activates the target into the desktop topology (needs a real WDDM GPU; on a
|
||||
/// GPU-less box this stays `None` even though ADD succeeded).
|
||||
pub(crate) unsafe fn resolve_gdi_name(target_id: u32) -> Option<String> {
|
||||
@@ -116,7 +216,7 @@ pub(crate) unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> {
|
||||
Some((dm.dmPelsWidth, dm.dmPelsHeight))
|
||||
}
|
||||
|
||||
/// Toggle the SudoVDA target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||
/// Toggle the virtual-display target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the
|
||||
/// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it
|
||||
/// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so
|
||||
/// WGC keeps HDR on the normal desktop. Returns true on a successful `DisplayConfigSetDeviceInfo`.
|
||||
@@ -153,7 +253,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
|
||||
target_id,
|
||||
enable,
|
||||
rc,
|
||||
"SudoVDA set advanced-color (HDR) state"
|
||||
"virtual-display set advanced-color (HDR) state"
|
||||
);
|
||||
return rc == 0;
|
||||
}
|
||||
@@ -165,7 +265,7 @@ pub(crate) unsafe fn set_advanced_color(target_id: u32, enable: bool) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Read the SudoVDA target's CURRENT advanced-color (HDR) state via the CCD API — i.e. whether HDR is
|
||||
/// Read the virtual-display target's CURRENT advanced-color (HDR) state via the CCD API — i.e. whether HDR is
|
||||
/// actually ON for the virtual display right now (e.g. because the user toggled it in Windows display
|
||||
/// settings). The capture/encode pipeline follows the monitor's real colorspace (WGC → FP16 → NVENC
|
||||
/// Main10 BT.2020 PQ), so this is the authoritative "is this an HDR session" signal — NOT the
|
||||
@@ -210,7 +310,7 @@ pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Force the freshly-added SudoVDA monitor to the client's exact `WxH@Hz`. The ADD IOCTL only
|
||||
/// Force the freshly-added virtual monitor to the client's exact `WxH@Hz`. The ADD IOCTL only
|
||||
/// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the
|
||||
/// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a
|
||||
/// mode the driver didn't advertise just leaves the default instead of erroring the session.
|
||||
@@ -221,7 +321,7 @@ pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) {
|
||||
|
||||
// Enumerate the modes the driver actually advertises for this output and pick the best match for
|
||||
// the requested RESOLUTION: the exact refresh if present, else the highest advertised refresh
|
||||
// <= requested, else the highest available at that resolution. The SudoVDA ADD IOCTL advertises
|
||||
// <= requested, else the highest available at that resolution. The pf-vdisplay ADD IOCTL advertises
|
||||
// the client mode, but a very high pixel rate (e.g. 5120x1440@240 = 1.77 Gpix/s) can be clamped
|
||||
// or absent — falling back to a lower refresh AT THE SAME RESOLUTION keeps the client's
|
||||
// resolution (what the user sees) instead of collapsing to the 1280x720/1920x1080 OS default.
|
||||
@@ -358,6 +458,11 @@ pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_M
|
||||
/// doesn't export it, so define it here.
|
||||
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
|
||||
|
||||
/// `DISPLAYCONFIG_PATH_MODE_IDX_INVALID` (wingdi.h) — "no mode pinned" for a path's source/target
|
||||
/// mode index; with `SDC_ALLOW_CHANGES` the OS picks the modes itself. Not exported by the `windows`
|
||||
/// crate either.
|
||||
const DISPLAYCONFIG_PATH_MODE_IDX_INVALID: u32 = 0xffff_ffff;
|
||||
|
||||
/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on
|
||||
/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and
|
||||
/// [`count_other_active`].
|
||||
|
||||
Reference in New Issue
Block a user