feat(resize/win): mid-stream resize on-glass fixes — corrective-ack actual res + monitor re-arrival

On-glass validation on the .173 Windows IDD-push host confirmed the Reconfigure
protocol + host rebuild work end-to-end and genuinely change pixels for an
advertised mode (1920x1080 -> 1280x720: two SPS/IDR sets, ffprobe both res). It
also surfaced two gaps for out-of-EDID-list target modes, both fixed here.

Fix 2 (corrective ack carries the ACTUAL resolution): the H2/H3 corrective ack
recovered only the achieved REFRESH (interval_hz), taking width/height straight
from the request — so when a backend delivered a different RESOLUTION (Windows
pf-vdisplay falling back to its advertised mode) the client was told it got a
size it never received, and by the D2 discipline never re-asked. New
`delivered_mode(frame.{w,h}, interval)` derives the ack from the captured frame's
real dims (what the encoder opened at / the client decodes) in both the success
and rollback branches. Unit-tested.

Fix 1 (reach arbitrary mid-stream modes via monitor RE-ARRIVAL): the pf-vdisplay
driver freezes a monitor's advertised mode list at IOCTL_ADD, and IddCx exposes
no live update-modes DDI, so an in-place ChangeDisplaySettingsExW to a mode not
advertised at arrival returns DISP_CHANGE_BADMODE. The manager's mid-stream
reconfigure now REMOVEs + re-ADDs the driver monitor at the exact new mode,
reusing the slot's stable per-client id (EDID serial / ContainerId) so the OS
keeps identity + saved DPI. The rebuilt Monitor PRESERVES gen (lease/refcount
continuity) and the group restore snapshot; reisolate_after_swap re-isolates the
new target without recapturing it. Host-only — no driver change. One monitor
hotplug per switch (the design's accepted "re-arrival for everything").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:34:22 +02:00
parent 89ff326ebf
commit 4c18bb80ca
2 changed files with 294 additions and 99 deletions
+73 -18
View File
@@ -3002,7 +3002,9 @@ struct SendStats {
/// stats-mode slot — one store/load instead of three racy ones. Every dimension fits: the codec
/// max dimension caps w/h well under 2^16 (`validate_dimensions`), refresh likewise.
fn pack_mode(width: u32, height: u32, refresh_hz: u32) -> u64 {
((width as u64 & 0xffff) << 32) | ((height as u64 & 0xffff) << 16) | (refresh_hz as u64 & 0xffff)
((width as u64 & 0xffff) << 32)
| ((height as u64 & 0xffff) << 16)
| (refresh_hz as u64 & 0xffff)
}
/// Unpack a [`pack_mode`] word back into `(width, height, refresh_hz)`.
@@ -3022,6 +3024,27 @@ fn interval_hz(interval: std::time::Duration) -> u32 {
(1.0 / interval.as_secs_f64()).round() as u32
}
/// The mode a pipeline is ACTUALLY delivering, for the H2/H3 corrective ack: the captured frame's
/// real dimensions (`build_pipeline` opens the encoder at `frame.{width,height}`, so this is exactly
/// what the client decodes) paced at the rate the pipeline achieved ([`interval_hz`]). It diverges
/// from the requested mode when a backend can't honor it: KWin caps a virtual output's refresh, or —
/// the case this exists for — Windows pf-vdisplay rejects an in-place `SetMode` to a resolution not
/// in the running monitor's advertised EDID list and the host falls back to the actual display mode
/// (`capture::idd_push`: "sizing the ring to the display's actual mode"). Comparing this against the
/// already-acked request decides whether a corrective `Reconfigured` ack is owed so the client
/// doesn't believe it got a resolution it never received.
fn delivered_mode(
frame_width: u32,
frame_height: u32,
interval: std::time::Duration,
) -> punktfunk_core::Mode {
punktfunk_core::Mode {
width: frame_width,
height: frame_height,
refresh_hz: interval_hz(interval),
}
}
#[allow(clippy::too_many_arguments)]
fn send_loop(
mut session: Session,
@@ -3703,7 +3726,10 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
Ok((new_vd, pipe))
})();
match rebuilt {
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen))) => {
Ok((
new_vd,
(new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen),
)) => {
// Replace the pipeline first (drops the old capturer → old PipeWire stream +
// virtual output), then the factory (drops e.g. the old KWin connection).
capturer = new_cap;
@@ -3764,15 +3790,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) {
crate::vdisplay::registry::retire(g);
}
// H2/H3: the backend may have honored a different refresh than requested (KWin
// caps virtual outputs it can't drive faster). Publish the ACTUAL mode to the
// live stats slot, and correct the client's mode slot when it differs from the
// accept ack it already got.
let actual = punktfunk_core::Mode {
width: new_mode.width,
height: new_mode.height,
refresh_hz: interval_hz(interval),
};
// H2/H3: the backend may have honored a different mode than requested KWin
// caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place
// SetMode to a resolution its running monitor doesn't advertise and the host
// falls back to the actual display mode. `frame` is the NEW pipeline's first
// frame (just rebound above), so its dims are what the client actually decodes.
// Publish that ACTUAL mode to the live stats slot, and correct the client's mode
// slot when it differs from the accept ack it already got.
let actual = delivered_mode(frame.width, frame.height, interval);
live_mode.store(
pack_mode(actual.width, actual.height, actual.refresh_hz),
Ordering::Relaxed,
@@ -3796,15 +3821,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// H2 rollback: the control task acked the switch BEFORE this rebuild, so the
// client's mode slot already flipped to `new_mode`. A second accepted ack
// carrying the still-live mode corrects it (any accepted ack means "the active
// mode is now X" client-side; old clients just log it). Refresh from the OLD
// pipeline's interval — the still-running one — in case its build was capped.
// mode is now X" client-side; old clients just log it). `frame` is untouched
// here (the destructure only runs on the Ok arm), so it's still the OLD
// pipeline's frame — its real dims + interval are exactly what's still on glass.
let _ = reconfig_result_tx.send(Reconfigured {
accepted: true,
mode: punktfunk_core::Mode {
width: cur_mode.width,
height: cur_mode.height,
refresh_hz: interval_hz(interval),
},
mode: delivered_mode(frame.width, frame.height, interval),
});
}
}
@@ -4560,6 +4582,39 @@ mod tests {
}
}
#[test]
fn delivered_mode_reports_captured_dims_and_triggers_corrective_ack() {
let hz60 = std::time::Duration::from_secs_f64(1.0 / 60.0);
let requested = punktfunk_core::Mode {
width: 2560,
height: 1440,
refresh_hz: 60,
};
// Honored: the captured frame matches the request → no corrective ack owed (`== requested`).
let honored = delivered_mode(2560, 1440, hz60);
assert_eq!(honored, requested);
// Resolution fallback (Windows pf-vdisplay rejected the out-of-list SetMode, host stayed at
// the actual display mode): the frame's real dims flow through, so the delivered mode differs
// from the acked request and a corrective ack IS owed — the exact gap this fixes.
let fell_back = delivered_mode(1920, 1080, hz60);
assert_ne!(fell_back, requested);
assert_eq!(
fell_back,
punktfunk_core::Mode {
width: 1920,
height: 1080,
refresh_hz: 60
}
);
// Refresh cap (KWin) is still caught: same dims, achieved rate recovered from the interval.
let capped = delivered_mode(2560, 1440, std::time::Duration::from_secs_f64(1.0 / 30.0));
assert_ne!(capped, requested);
assert_eq!(capped.refresh_hz, 30);
}
#[test]
fn pad_snapshot_replaces_state_and_seq_gates() {
use punktfunk_core::input::{gamepad, GamepadSnapshot};