From 35d97ae6acd97860bb6f541fe571b6fab5d59af1 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 01:06:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(windows):=20parallel=20virtual=20displays?= =?UTF-8?q?=20=E2=80=94=20proto=20v3=20ring=20binding,=20manager=20slot=20?= =?UTF-8?q?map,=20group=20topology=20(W0=E2=80=93W3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/windows-parallel-virtual-displays.md (display-management Stage 7 / §6.6): N simultaneously-live pf-vdisplay monitors, one sealed ring each, every idd-push-security invariant preserved per-ring. - proto v3: SharedHeader._pad → target_id — the ring NAMES its monitor, host-stamped before the magic; the driver publisher refuses a cross-bound ring via the shared, unit-tested frame::check_attach (new DRV_STATUS_BIND_FAIL — the gamepad pad_index validation applied to frames, invariant #10); the host's wait_for_attach surfaces the refusal loudly and self-checks its own stamp. - manager: the one-monitor MgrState becomes a slot map keyed by the client's identity slot (0 = anonymous/GameStream); per-slot reconnect + dead-WUDFHost preempts, slot-scoped begin_idd_setup (a different identity is an admission question, never a preempt), ONE device-level watchdog pinger, per-slot /display/state + /display/release. - group topology: isolate_displays_ccd takes the managed target SET (a sibling slot is never deactivated); SavedConfig + the DDC/PnP axes move to the group record (first-in captures, last-out restores); desktop layout via CCD source origins from the pure layout::arrange (auto-row default, manual pins win), re-applied on create + reconfigure. - admission: the Windows separate→reject override now sits behind the PUNKTFUNK_WIN_SEPARATE=1 validation hatch (the wedge it guarded is structurally gone — a second identity gets its own monitor + ring; default flips in W5 after soak); max_displays and NVENC session-unit budgets decline an unaffordable display AT admission; kick_dwm_compose is process-globally throttled and per-display — cursor jump + 35 ms dwell (a sub-tick jump composes nothing; DWM reads dirties from current state at the next vsync tick). On-glass on the RTX box: V1/V2/V4/V5/V6/V9 green — two paired clients on two monitors streaming ~60 fps each with zero mismatches and zero bind failures, churn-hammer clean (no 0x80070490), per-ring mode-change recreate leaves the sibling untouched, typed budget rejection, fault-injected cross-bind refused loudly with the sibling undisturbed. V7: WUDFHost-kill shared fate is clean; in-process device recovery is a known follow-up (the retired-never-closed control handles block the adapter cycle — reset-pf-vdisplay.ps1 recovers). DWM composes two IDD monitors concurrently at 60 fps — the plan's load-bearing unknown, answered yes. Also carries the client-HDR EDID forwarding that shared this working tree (Hello::display_hdr → AddRequest luminance tail → the monitor's CTA-861.3 HDR block, PUNKTFUNK_CLIENT_PEAK_NITS hatch) and the Deck client fixes (40 ms rumble keep-alive with 1-LSB jitter, HDR self-diagnosing presenter warn, flatpak HDR env). Co-Authored-By: Claude Fable 5 --- clients/android/native/src/session/connect.rs | 3 + clients/linux/src/app.rs | 3 +- clients/probe/src/main.rs | 4 + clients/session/src/main.rs | 4 + clients/windows/src/present.rs | 46 + clients/windows/src/session.rs | 44 +- crates/pf-client-core/src/gamepad.rs | 90 +- crates/pf-client-core/src/session.rs | 8 + crates/pf-driver-proto/src/lib.rs | 284 ++++- crates/pf-presenter/src/vk.rs | 19 + crates/punktfunk-core/src/abi.rs | 4 + crates/punktfunk-core/src/client.rs | 38 + crates/punktfunk-core/src/quic/datagram.rs | 57 +- crates/punktfunk-core/src/quic/msgs.rs | 36 +- crates/punktfunk-core/src/quic/tests.rs | 70 ++ .../src/capture/windows/idd_push.rs | 119 +- crates/punktfunk-host/src/encode.rs | 17 + .../src/encode/windows/nvenc.rs | 54 +- .../punktfunk-host/src/gamestream/stream.rs | 11 +- crates/punktfunk-host/src/hdr.rs | 32 + crates/punktfunk-host/src/punktfunk1.rs | 54 +- crates/punktfunk-host/src/vdisplay.rs | 9 + .../punktfunk-host/src/vdisplay/admission.rs | 46 +- .../punktfunk-host/src/vdisplay/registry.rs | 24 +- .../src/vdisplay/windows/manager.rs | 1042 +++++++++++------ .../src/vdisplay/windows/pf_vdisplay.rs | 32 +- .../punktfunk-host/src/windows/win_display.rs | 124 +- include/punktfunk_core.h | 19 +- packaging/flatpak/io.unom.Punktfunk.yml | 16 + .../drivers/pf-vdisplay/src/control.rs | 42 +- .../windows/drivers/pf-vdisplay/src/edid.rs | 39 +- .../pf-vdisplay/src/frame_transport.rs | 84 +- .../drivers/pf-vdisplay/src/monitor.rs | 11 +- .../pf-vdisplay/src/swap_chain_processor.rs | 4 + 34 files changed, 1945 insertions(+), 544 deletions(-) diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 860c366f..46526882 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -161,6 +161,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo } }, preferred_codec.clamp(0, u8::MAX as jint) as u8, + // No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the + // Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults. + None, launch, // a store-qualified library id to boot into a game, or None for the desktop pin, // Some → Crypto on host-fp mismatch identity, // owned (cert, key) PEM, or None (anonymous) diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 3c053750..df82d72f 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -496,7 +496,8 @@ impl AppModel { 2, // audio_channels: stereo crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) 0, // preferred_codec: no preference - None, // launch: probe connect, no game + None, // display_hdr: probe connect, nothing presents + None, // launch: probe connect, no game pin, Some(identity), std::time::Duration::from_secs(15), diff --git a/clients/probe/src/main.rs b/clients/probe/src/main.rs index 42a10a89..5d213e3b 100644 --- a/clients/probe/src/main.rs +++ b/clients/probe/src/main.rs @@ -491,6 +491,10 @@ async fn session(args: Args) -> Result<()> { | punktfunk_core::quic::CODEC_AV1, // `--codec` soft preference (0 = auto). The host honors it when it can emit it. preferred_codec: args.preferred_codec, + // PUNKTFUNK_CLIENT_PEAK_NITS= advertises a synthetic display volume — the host + // writes it into the virtual display's EDID (CTA HDR block), so the EDID-forwarding + // path can be validated headlessly (check the host's monitor caps / ADD log line). + display_hdr: punktfunk_core::client::display_hdr_env_override(), } .encode(), ) diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index f8cfe914..972b8859 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -146,6 +146,10 @@ mod session_main { } else { 0 }, + // No portable Wayland/X11 display-volume query yet, so the host keeps its EDID + // defaults for Linux clients; `PUNKTFUNK_CLIENT_PEAK_NITS` (read in the session + // pump) pins one manually. + display_hdr: None, mic_enabled: settings.mic_enabled, // The Settings preference (auto → VAAPI where it exists; the presenter // demotes to software on boxes whose Vulkan can't import the dmabufs). diff --git a/clients/windows/src/present.rs b/clients/windows/src/present.rs index 5f5e55f1..80dc8b04 100644 --- a/clients/windows/src/present.rs +++ b/clients/windows/src/present.rs @@ -834,6 +834,52 @@ pub fn display_supports_hdr() -> bool { false } +/// The HDR display's colour volume from `IDXGIOutput6::GetDesc1` — the first output currently in +/// HDR (BT.2020 PQ) mode, as [`HdrMeta`](punktfunk_core::quic::HdrMeta) for `Hello::display_hdr`. +/// The host writes this volume into its virtual display's EDID, so host apps tone-map to THIS +/// panel and the PQ stream needs no client-side rescue. Chromaticities come as CIE xy floats +/// (×50000 → ST.2086 units, G/B/R order); luminances as nits floats (max ×10000 → 0.0001-cd/m² +/// units); `MaxFullFrameLuminance` → MaxFALL (whole nits); MaxCLL stays 0 (a display has no +/// content light level). Same ANY-output coarseness as [`display_supports_hdr`] — the session +/// gates on that check first, so both look at the same panel in the single-HDR-display case. +pub fn display_hdr_volume() -> Option { + let to_2086 = |v: f32| (v * 50000.0).round().clamp(0.0, 65535.0) as u16; + unsafe { + let factory: IDXGIFactory1 = CreateDXGIFactory1().ok()?; + let mut ai = 0u32; + while let Ok(adapter) = factory.EnumAdapters1(ai) { + ai += 1; + let mut oi = 0u32; + while let Ok(output) = adapter.EnumOutputs(oi) { + oi += 1; + let Ok(o6) = output.cast::() else { + continue; + }; + let Ok(desc) = o6.GetDesc1() else { continue }; + if desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 { + continue; + } + return Some(punktfunk_core::quic::HdrMeta { + // ST.2086 order is G, B, R. + display_primaries: [ + [to_2086(desc.GreenPrimary[0]), to_2086(desc.GreenPrimary[1])], + [to_2086(desc.BluePrimary[0]), to_2086(desc.BluePrimary[1])], + [to_2086(desc.RedPrimary[0]), to_2086(desc.RedPrimary[1])], + ], + white_point: [to_2086(desc.WhitePoint[0]), to_2086(desc.WhitePoint[1])], + max_display_mastering_luminance: (desc.MaxLuminance.max(0.0) * 10_000.0).round() + as u32, + min_display_mastering_luminance: (desc.MinLuminance.max(0.0) * 10_000.0).round() + as u32, + max_cll: 0, + max_fall: desc.MaxFullFrameLuminance.max(0.0).round() as u16, + }); + } + } + } + None +} + /// Generic HDR10 mastering metadata: BT.2020 primaries + D65 white, a 1000-nit mastering display, /// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives. fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 { diff --git a/clients/windows/src/session.rs b/clients/windows/src/session.rs index 14faedb3..6f4c7465 100644 --- a/clients/windows/src/session.rs +++ b/clients/windows/src/session.rs @@ -144,6 +144,7 @@ pub fn run_speed_probe( 2, // audio_channels: stereo baseline crate::video::decodable_codecs(), 0, // preferred_codec: no preference + None, // display_hdr: probe connect, nothing presents None, // launch: no game pin, Some(identity), @@ -235,6 +236,34 @@ fn pump( frame_rx: FrameRx, stop: Arc, ) { + // Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR + // mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still + // applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream + // rather than PQ the panel would mis-tone-map (washed-out/dark). The presenter handles BT.2020 + // PQ frames (P010 / X2BGR10). + let hdr_active = params.hdr_enabled && crate::present::display_supports_hdr(); + if params.hdr_enabled && !hdr_active { + tracing::info!("HDR enabled in settings but no HDR display detected — requesting SDR"); + } + // With HDR active, also report the panel's real colour volume (GetDesc1): the host writes it + // into its virtual display's EDID, so host apps tone-map to THIS panel and the PQ stream + // arrives already inside its volume — the client presents it untouched. + // PUNKTFUNK_CLIENT_PEAK_NITS pins a synthetic volume for A/B runs. + let display_hdr = if hdr_active { + let vol = punktfunk_core::client::display_hdr_env_override() + .or_else(crate::present::display_hdr_volume); + if let Some(m) = vol { + tracing::info!( + max_nits = m.max_display_mastering_luminance / 10_000, + min_millinits = m.min_display_mastering_luminance / 10, + max_fall = m.max_fall, + "advertising this display's HDR volume to the host" + ); + } + vol + } else { + None + }; let connector = match NativeClient::connect( ¶ms.host, params.port, @@ -242,25 +271,16 @@ fn pump( params.compositor, params.gamepad, params.bitrate_kbps, - // Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR - // mode: the host then upgrades HDR content to a Main10/PQ stream (its own 10-bit gate still - // applies). On an SDR display we advertise `0` so the host sends a proper 8-bit BT.709 stream - // rather than PQ the panel would mis-tone-map (washed-out/dark). An HDR display self-tone-maps - // from the mastering metadata we apply. The presenter handles BT.2020 PQ frames (P010 / X2BGR10). - if params.hdr_enabled && crate::present::display_supports_hdr() { + if hdr_active { punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR } else { - if params.hdr_enabled { - tracing::info!( - "HDR enabled in settings but no HDR display detected — requesting SDR" - ); - } 0 }, params.audio_channels, crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) params.preferred_codec, // the user's soft codec preference (0 = auto) - None, // launch: the Windows client has no library picker yet + display_hdr, + None, // launch: the Windows client has no library picker yet params.pin, Some(params.identity), params.connect_timeout, diff --git a/crates/pf-client-core/src/gamepad.rs b/crates/pf-client-core/src/gamepad.rs index 3024dd0a..e8a44577 100644 --- a/crates/pf-client-core/src/gamepad.rs +++ b/crates/pf-client-core/src/gamepad.rs @@ -61,6 +61,16 @@ const ESCAPE_CHORD: [u32; 4] = [wire::BTN_LB, wire::BTN_RB, wire::BTN_START, wir /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press). const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); +/// Steam Deck built-in haptic keep-alive interval. The Deck's actuator decays inside SDL's +/// ~2 s internal rumble resend (`SDL_RUMBLE_RESEND_MS`), and SDL short-circuits a repeated +/// identical `set_rumble` value to a no-op device write — so a STEADY host value (which the +/// host delivers only as unchanging 500 ms refreshes) never re-kicks the motor and is felt as +/// a periodic pulse. We re-issue below the decay so the bursts fuse into a continuous buzz; +/// 40 ms mirrors SDL's sibling Steam-Controller driver keep-alive. Deck-only (see +/// [`Worker::issue_rumble`]); every other pad sustains rumble at the hardware level and is +/// left untouched. +const DECK_RUMBLE_KEEPALIVE_MS: u64 = 40; + /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). const MENU_DEADZONE: u16 = 16384; @@ -641,6 +651,13 @@ struct Worker { menu_mode: bool, menu_nav: MenuNav, menu_tx: async_channel::Sender, + /// Last rumble value handed to the active pad (the logical host value, pre-jitter) and + /// when — drives the Steam Deck haptic keep-alive in [`Worker::render_feedback`]. + rumble_last: (u16, u16), + rumble_last_at: Option, + /// Toggles the 1-LSB low-motor nudge that forces SDL past its identical-value dedupe on a + /// Deck keep-alive re-issue (see [`Worker::issue_rumble`]). + rumble_jitter: bool, } impl Worker { @@ -1225,6 +1242,36 @@ impl Worker { } } + /// Hand a rumble value to SDL on the active pad, remembering it for the Deck keep-alive. + /// SDL short-circuits an identical `(low, high)` with NO device write (it only re-arms its + /// expiration), so on a Deck keep-alive re-issue of the same non-zero value we flip a single + /// low-motor LSB — an imperceptible amplitude nudge — to force the write through and keep the + /// actuator physically fed. The 1500 ms SDL duration is kept on every issue so SDL's logical + /// expiration is continuously refreshed and a genuine sustained rumble never dies at 1.5 s. + fn issue_rumble(&mut self, low: u16, high: u16, deck: bool) { + let (out_low, out_high) = + if deck && (low, high) == self.rumble_last && (low, high) != (0, 0) { + self.rumble_jitter = !self.rumble_jitter; + (low ^ self.rumble_jitter as u16, high) + } else { + (low, high) + }; + match self + .open + .as_mut() + .map(|(_, p)| p.set_rumble(out_low, out_high, 1_500)) + { + // Surface a failed SDL rumble write: a swallowed error here (DualSense not in the + // right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The host logs + // the send side on 0xCA, so the two together pinpoint host-game vs client-render. + Some(Err(e)) => tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"), + Some(Ok(())) => tracing::debug!(low, high, "rumble: rendered"), + None => tracing::debug!(low, high, "rumble: received but no active pad to render"), + } + self.rumble_last = (low, high); + self.rumble_last_at = Some(Instant::now()); + } + /// Drain and render the feedback planes — rumble plus HID output (lightbar / /// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only @@ -1236,23 +1283,37 @@ impl Worker { let Some(connector) = self.attached.clone() else { return; }; + // The Steam Deck's built-in haptic actuator decays inside SDL's ~2 s internal rumble + // resend, and SDL dedupes an unchanged `set_rumble` value to a no-op device write — so a + // steady host value (delivered only as identical 500 ms refreshes) is felt as a periodic + // pulse rather than a continuous buzz. Detect the Deck pad here and keep it fed below the + // decay (`DECK_RUMBLE_KEEPALIVE_MS`); every other pad sustains at the hardware level. + let deck = self + .open + .as_ref() + .and_then(|(id, _)| self.pad_info(*id)) + .is_some_and(|p| matches!(p.pref, GamepadPref::SteamDeck)); + let mut fresh = false; while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) { if pad == 0 { - if let Some((_, p)) = self.open.as_mut() { - // Surface a failed SDL rumble write: a swallowed error here (DualSense not in - // the right HIDAPI mode, etc.) reads exactly like "rumble doesn't work". The - // host logs the send side on 0xCA, so the two together pinpoint host-game vs - // client-render. - if let Err(e) = p.set_rumble(low, high, 1_500) { - tracing::warn!(low, high, error = %e, "rumble: SDL set_rumble failed"); - } else { - tracing::debug!(low, high, "rumble: rendered"); - } - } else { - tracing::debug!(low, high, "rumble: received but no active pad to render"); - } + fresh = true; + self.issue_rumble(low, high, deck); } } + // Deck keep-alive: no fresh datagram this tick but a non-zero value is latched — re-kick + // the actuator so its discrete haptic bursts fuse into a continuous buzz instead of a + // ~2 s pulse. Zero is left alone (a real stop must stay stopped); non-Deck pads never + // enter here (`deck` is false), so their behaviour is byte-for-byte unchanged. + if deck + && !fresh + && self.rumble_last != (0, 0) + && self + .rumble_last_at + .is_none_or(|t| t.elapsed() >= Duration::from_millis(DECK_RUMBLE_KEEPALIVE_MS)) + { + let (low, high) = self.rumble_last; + self.issue_rumble(low, high, deck); + } while let Ok(hid) = connector.next_hidout(Duration::ZERO) { let is_ds = self .open @@ -1318,6 +1379,9 @@ impl Worker { menu_mode: false, menu_nav: MenuNav::new(), menu_tx, + rumble_last: (0, 0), + rumble_last_at: None, + rumble_jitter: false, } } } diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 0fc79dea..9e92cd9d 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -33,6 +33,11 @@ pub struct SessionParams { /// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0` /// when the user turned HDR off in Settings ("never send me 10-bit"). pub video_caps: u8, + /// This display's HDR colour volume (primaries/white/luminance), when the embedder can read + /// it from the OS. Rides `Hello::display_hdr` → the host's virtual-display EDID, so host apps + /// tone-map to THIS panel. `None` = unknown/SDR (host EDID defaults). Overridable for testing + /// via `PUNKTFUNK_CLIENT_PEAK_NITS` (synthesizes a BT.2020 volume at that peak). + pub display_hdr: Option, /// Stream the default microphone to the host's virtual mic source. pub mic_enabled: bool, /// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see @@ -221,6 +226,9 @@ fn pump( params.audio_channels, crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) params.preferred_codec, // the user's soft codec preference (0 = auto) + // This display's HDR volume → the host's virtual-display EDID. The env hatch wins so an + // A/B run can pin an exact peak (PUNKTFUNK_CLIENT_PEAK_NITS=600). + punktfunk_core::client::display_hdr_env_override().or(params.display_hdr), params.launch.clone(), params.pin, Some(params.identity), diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 49615425..1439c56c 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -53,7 +53,13 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) { /// ([`control::IOCTL_SET_FRAME_CHANNEL`]), and [`control::AddReply`] grew `wudf_pid` (the duplication /// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is /// incompatible by design. -pub const PROTOCOL_VERSION: u32 = 2; +/// v3: ring↔monitor binding hardening for parallel displays +/// (`design/windows-parallel-virtual-displays.md` §3): [`frame::SharedHeader`] names its monitor +/// (`target_id`, the former `_pad` — same size, same offsets) and the driver's publisher refuses to +/// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's +/// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver +/// against a v2 host would refuse every attach — lockstep by the handshake, as ever. +pub const PROTOCOL_VERSION: u32 = 3; /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. pub const fn ctl_code(func: u32) -> u32 { @@ -89,6 +95,13 @@ pub mod control { /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this /// mode as preferred; the host still CCD-forces the active mode (the OS activates IDDs at a default). + /// + /// **Size compatibility**: the client-HDR luminance tail (the three fields after + /// `preferred_monitor_id`) was appended without a protocol bump because BOTH directions degrade + /// cleanly: an un-upgraded driver reads the [`ADD_REQUEST_LEGACY_SIZE`]-byte prefix of a new + /// host's request (its `read_input` accepts a larger buffer) and keeps its built-in EDID + /// luminance; an upgraded driver accepts a legacy-size request and zero-fills the tail (`0` = + /// unknown → the built-in defaults). Any FURTHER field must follow the same discipline. #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] pub struct AddRequest { @@ -104,8 +117,26 @@ pub mod control { /// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded /// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`]. pub preferred_monitor_id: u32, + /// The CLIENT display's peak luminance in nits — written into this monitor's EDID CTA-861.3 + /// HDR static-metadata block (Desired Content Max Luminance), so host apps and the OS + /// tone-map to the panel the stream actually lands on instead of the driver's built-in + /// ~1000-nit placeholder. `0` = unknown → the driver keeps its built-in default block. + pub max_luminance_nits: u32, + /// The client display's max frame-average luminance in nits (→ Desired Content Max + /// Frame-average Luminance). `0` = unknown/not indicated. + pub max_frame_avg_nits: u32, + /// The client display's min luminance in MILLI-nits (0.001 cd/m² — the CTA min-luminance + /// range lives well below 1 nit) → Desired Content Min Luminance. `0` = unknown. + pub min_luminance_millinits: u32, + /// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding); + /// free expansion room for the next appended field. + pub _reserved: u32, } + /// [`AddRequest`]'s size before the client-HDR luminance tail — the prefix an un-upgraded + /// driver reads and the whole request an un-upgraded host sends (see the struct docs). + pub const ADD_REQUEST_LEGACY_SIZE: usize = 24; + /// `IOCTL_ADD` reply: the OS target id + the adapter LUID the IDD landed on (split low/high to /// match `windows` `LUID { LowPart: u32, HighPart: i32 }`). #[repr(C)] @@ -193,12 +224,16 @@ pub mod control { const _: () = { use core::mem::{offset_of, size_of}; - assert!(size_of::() == 24); + assert!(size_of::() == 40); assert!(offset_of!(AddRequest, session_id) == 0); assert!(offset_of!(AddRequest, width) == 8); assert!(offset_of!(AddRequest, height) == 12); assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); + // The client-HDR luminance tail starts exactly at the legacy boundary (prefix-compat). + assert!(offset_of!(AddRequest, max_luminance_nits) == ADD_REQUEST_LEGACY_SIZE); + assert!(offset_of!(AddRequest, max_frame_avg_nits) == 28); + assert!(offset_of!(AddRequest, min_luminance_millinits) == 32); assert!(size_of::() == 20); assert!(offset_of!(AddReply, adapter_luid_low) == 0); @@ -228,6 +263,88 @@ pub mod control { }; } +/// CTA-861.3 "Desired Content Luminance" coding for the pf-vdisplay EDID's HDR Static Metadata +/// Data Block — the three bytes that tell Windows (and through it every host app) what luminance +/// volume the virtual display's panel has. The HOST fills [`control::AddRequest`]'s luminance +/// fields from the CLIENT's real display volume and the DRIVER codes them here, so games tone-map +/// to the panel the stream actually lands on. +/// +/// Lives in this shared crate (not the driver) deliberately: the driver only builds under the WDK +/// on Windows, but this byte-level coding is exactly the fiddly part that wants unit tests on +/// every dev machine BEFORE a driver build/sign/deploy cycle. `no_std` + integer-only (fixed +/// point), so it drops into the driver unchanged. +pub mod edid { + /// `2^(k/32)` for `k = 0..32` in Q16 fixed point (`round(2^(k/32) * 65536)`) — the fractional + /// step table for the CTA-861.3 luminance exponent. + const POW2_Q16: [u32; 32] = [ + 65536, 66971, 68438, 69936, 71468, 73032, 74632, 76266, 77936, 79642, 81386, 83169, 84990, + 86851, 88752, 90696, 92682, 94711, 96785, 98905, 101070, 103283, 105545, 107856, 110218, + 112631, 115098, 117618, 120194, 122825, 125515, 128263, + ]; + + /// Decode a CTA-861.3 max / frame-average luminance code to MILLI-nits: + /// `L = 50 * 2^(CV/32)` cd/m², so `L_millinits = 50_000 * 2^(CV/32)`. + /// (`CV = 255` ≈ 12_525 nits — comfortably inside u64 at Q16.) + pub const fn cta_max_millinits(code: u8) -> u64 { + let whole = code as u32 / 32; + let frac = code as u32 % 32; + ((50_000u64 << whole) * POW2_Q16[frac as usize] as u64) >> 16 + } + + /// Code a display's peak (or frame-average) luminance in nits as a CTA-861.3 luminance value: + /// the LARGEST code whose decoded luminance does not exceed the panel's — never advertise a + /// volume brighter than the glass, so a host app's tone map can't clip on the client. Clamped + /// to `1..=255`: `0` is "no data" on the wire, and callers gate on `nits > 0` themselves (a + /// sub-51-nit request — no real HDR panel — still codes as 1). + pub fn cta_max_luminance_code(nits: u32) -> u8 { + let target = nits as u64 * 1000; + let mut code = 1u8; + while code < 255 && cta_max_millinits(code + 1) <= target { + code += 1; + } + code + } + + /// Floor integer square root (Newton's method — `u64::isqrt` needs Rust 1.84, above this + /// crate's 1.82 MSRV). Converges in ≤ 6 iterations from the power-of-two seed. + fn isqrt_u64(x: u64) -> u64 { + if x == 0 { + return 0; + } + // Seed strictly above sqrt(x): 2^(ceil(bits/2)). + let mut r = 1u64 << (64 - x.leading_zeros()).div_ceil(2); + loop { + let next = (r + x / r) / 2; + if next >= r { + return r; + } + r = next; + } + } + + /// Code a display's min luminance (MILLI-nits) as the CTA-861.3 min-luminance value, which is + /// relative to the block's coded max: `L_min = L_max * (CV/255)^2 / 100`, so + /// `CV = 255 * sqrt(100 * L_min / L_max)` — rounded to nearest. `max_code` is the byte + /// produced by [`cta_max_luminance_code`]; a result of `0` (a true-black panel, or + /// `millinits = 0` = unknown) is valid on the wire. + pub fn cta_min_luminance_code(millinits: u32, max_code: u8) -> u8 { + let max_millinits = cta_max_millinits(max_code); + if millinits == 0 || max_millinits == 0 { + return 0; + } + // CV = sqrt(100 * 255^2 * L_min / L_max); round to nearest by comparing the two flanking + // squares (the integer sqrt floors). + let x = (100u64 * 255 * 255).saturating_mul(millinits as u64) / max_millinits; + let floor = isqrt_u64(x); + let cv = if (floor + 1) * (floor + 1) - x <= x - floor * floor { + floor + 1 + } else { + floor + }; + cv.min(255) as u8 + } +} + /// The IDD-push frame transport: the host-created shared ring header, the publish token, and the /// driver-status codes. The texture ring itself is host-created **unnamed** D3D11 keyed-mutex textures; /// the driver reaches them (and the header + event) only through handles the host duplicated into its @@ -255,6 +372,12 @@ pub mod frame { pub const DRV_STATUS_TEX_FAIL: u32 = 2; /// Driver has no `ID3D11Device1` to open shared resources. pub const DRV_STATUS_NO_DEVICE1: u32 = 3; + /// Driver refused the attach because the mapped ring names a DIFFERENT monitor + /// ([`SharedHeader::target_id`] != the monitor the delivery landed on) — a host stash cross-wire + /// or stale-delivery race that, with parallel displays, would carry one client's frames into + /// another client's stream. Fail-closed binding validation (v3, invariant #10 of + /// `design/idd-push-security.md`); `driver_status_detail` carries the target id the ring claims. + pub const DRV_STATUS_BIND_FAIL: u32 = 4; /// The shared metadata header (host-created, mapped by both sides). Atomic fields (`magic`, `latest`, /// `generation`) are accessed via each side's own atomic view over the mapping; this is the layout. @@ -272,7 +395,14 @@ pub mod frame { pub width: u32, pub height: u32, pub dxgi_format: u32, - pub _pad: u32, + /// The OS target id of the monitor this ring belongs to (v3 — the former `_pad`, same + /// offset). Host-stamped at ring creation, BEFORE the magic (the magic-last publish ordering + /// guarantees the driver never reads it half-initialized) and never changed afterwards (a + /// mid-session recreate reuses the mapping, so the binding is stable for the ring's life). + /// The driver's publisher attaches only when it equals the monitor's own target id + /// ([`check_attach`]) — a mis-delivered ring fails closed ([`DRV_STATUS_BIND_FAIL`]) instead + /// of carrying another display's frames (invariant #10, `design/idd-push-security.md`). + pub target_id: u32, /// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`]. pub latest: u64, pub qpc_pts: u64, @@ -285,6 +415,43 @@ pub mod frame { pub driver_status_detail: u32, } + /// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the + /// two reject outcomes of [`check_attach`], each with different driver behavior. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum AttachReject { + /// The header isn't (or is no longer) the ring this delivery described: magic missing, or + /// the host recreated the ring again before the attach (a fresh delivery is on its way). + /// Benign — drop the delivery silently; no status is written. + Stale, + /// The ring names a DIFFERENT monitor (`SharedHeader::target_id` mismatch) — a host + /// stash/delivery cross-wire that, with parallel displays, would publish this monitor's + /// frames into another client's stream. Fail closed: refuse the attach and write + /// [`DRV_STATUS_BIND_FAIL`] so the host's wait-for-attach fails the open loudly. + BindMismatch, + } + + /// The publisher's attach precondition (v3): given the mapped header's `magic`, `generation` + /// and `target_id` plus the delivery's generation and the monitor's own target id, decide + /// whether the attach may proceed. Staleness is checked FIRST — a superseded delivery's binding + /// is meaningless (the fresh delivery re-validates it), so it never false-alarms as a bind + /// failure. Pure and shared-crate-owned so the reject paths are unit-tested on every dev + /// machine (the driver workspace builds `panic = "abort"` and cannot host a test harness). + pub fn check_attach( + magic: u32, + header_generation: u32, + header_target_id: u32, + delivery_generation: u32, + monitor_target_id: u32, + ) -> Result<(), AttachReject> { + if magic != MAGIC || header_generation != delivery_generation { + return Err(AttachReject::Stale); + } + if header_target_id != monitor_target_id { + return Err(AttachReject::BindMismatch); + } + Ok(()) + } + /// The `SharedHeader.latest` publish token: `(generation << 40) | (seq << 8) | slot`. /// `generation` is 24-bit, `seq` 32-bit, `slot` 8-bit. The generation tag lets the host REJECT a /// publish from a stale ring (an old-generation publisher racing a mid-session recreate) so it never @@ -316,8 +483,9 @@ pub mod frame { } // Size + per-field offsets are load-bearing: both sides access these via raw atomic views over the - // mapping, so a same-size field reorder would silently corrupt. Pin every offset. The `_pad` after - // `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too. + // mapping, so a same-size field reorder would silently corrupt. Pin every offset. `target_id` + // (v3, the former `_pad`) after `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — + // assert that too. const _: () = { use core::mem::{offset_of, size_of}; @@ -329,7 +497,7 @@ pub mod frame { assert!(offset_of!(SharedHeader, width) == 16); assert!(offset_of!(SharedHeader, height) == 20); assert!(offset_of!(SharedHeader, dxgi_format) == 24); - assert!(offset_of!(SharedHeader, _pad) == 28); + assert!(offset_of!(SharedHeader, target_id) == 28); assert!(offset_of!(SharedHeader, latest) == 32); assert!(offset_of!(SharedHeader, qpc_pts) == 40); assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48); @@ -588,12 +756,52 @@ mod tests { h.magic = frame::MAGIC; h.width = 5120; h.height = 1440; + h.target_id = 262; let bytes = bytemuck::bytes_of(&h); assert_eq!(bytes.len(), 64); let back: frame::SharedHeader = *bytemuck::from_bytes(bytes); assert_eq!(back.magic, frame::MAGIC); assert_eq!(back.width, 5120); assert_eq!(back.height, 1440); + // v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2 + // host left it zero there). + assert_eq!(bytes[28..32], 262u32.to_le_bytes()); + } + + #[test] + fn attach_check_binds_ring_to_monitor() { + use frame::{check_attach, AttachReject, MAGIC}; + // The good path: magic + matching generation + matching monitor binding. + assert_eq!(check_attach(MAGIC, 7, 262, 7, 262), Ok(())); + // Missing magic / superseded generation → Stale (silent drop, re-delivery coming) — and + // staleness WINS over a binding mismatch, since a superseded delivery's binding is + // meaningless (the fresh one re-validates). + assert_eq!( + check_attach(0, 7, 262, 7, 262), + Err(AttachReject::Stale), + "no magic" + ); + assert_eq!( + check_attach(MAGIC, 8, 262, 7, 262), + Err(AttachReject::Stale), + "recreated ring" + ); + assert_eq!( + check_attach(0, 8, 999, 7, 262), + Err(AttachReject::Stale), + "stale outranks bind" + ); + // The v3 hardening: a fresh, magic-valid ring naming a DIFFERENT monitor fails closed. + assert_eq!( + check_attach(MAGIC, 7, 999, 7, 262), + Err(AttachReject::BindMismatch) + ); + // A v2-host header (never stamped, target_id = 0) also fails closed against a v3 driver — + // the GET_INFO handshake rejects that pairing first, but the channel must not rely on it. + assert_eq!( + check_attach(MAGIC, 7, 0, 7, 262), + Err(AttachReject::BindMismatch) + ); } #[test] @@ -604,12 +812,32 @@ mod tests { height: 2160, refresh_hz: 120, preferred_monitor_id: 7, + max_luminance_nits: 800, + max_frame_avg_nits: 400, + min_luminance_millinits: 50, // 0.05 nits + _reserved: 0, }; let bytes = bytemuck::bytes_of(&req); - assert_eq!(bytes.len(), 24); + assert_eq!(bytes.len(), 40); assert_eq!(*bytemuck::from_bytes::(bytes), req); // preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible. assert_eq!(bytes[20..24], 7u32.to_le_bytes()); + // The client-HDR luminance tail rides after the legacy boundary; a zero-filled tail decodes + // as "unknown" (the un-upgraded-host form the driver's legacy read synthesizes). + assert_eq!(bytes[24..28], 800u32.to_le_bytes()); + let mut legacy = [0u8; 40]; + legacy[..control::ADD_REQUEST_LEGACY_SIZE] + .copy_from_slice(&bytes[..control::ADD_REQUEST_LEGACY_SIZE]); + let old = *bytemuck::from_bytes::(&legacy); + assert_eq!(old.preferred_monitor_id, 7); + assert_eq!( + ( + old.max_luminance_nits, + old.max_frame_avg_nits, + old.min_luminance_millinits + ), + (0, 0, 0) + ); let reply = control::AddReply { adapter_luid_low: 0x1234_5678, @@ -702,6 +930,48 @@ mod tests { } } + #[test] + fn cta_luminance_codes_hit_the_reference_points() { + // The driver's historical built-in EDID block: 0x8A ≈ 993 nits, 0x60 = 400 nits (exact), + // 0x12 for a ~0.05-nit floor. Our coder must land on the same bytes for those volumes. + assert_eq!(edid::cta_max_millinits(0x60), 400_000); // 50·2^3 exactly + assert_eq!(edid::cta_max_millinits(0x8A) / 1000, 993); + assert_eq!(edid::cta_max_luminance_code(400), 0x60); + // 0x8A decodes to 993.481 nits, so 994 is the smallest whole-nit input that reaches it + // under the never-advertise-brighter floor. + assert_eq!(edid::cta_max_luminance_code(994), 0x8A); + assert_eq!(edid::cta_min_luminance_code(50, 0x8A), 0x12); // 0.05 nits @ a 993-nit max + // Floor semantics: never advertise brighter than the panel. 1000 nits sits between + // code 138 (993) and 139 (~1015) → 138. + assert_eq!(edid::cta_max_luminance_code(1000), 138); + assert!(edid::cta_max_millinits(edid::cta_max_luminance_code(1000)) <= 1_000_000); + // Every real code decodes below or at its input (round-down), within one step (~2.2%). + // (Starts above code 1's 51.094 nits — beneath that the documented clamp-to-1 wins.) + for nits in [52u32, 80, 120, 250, 400, 604, 800, 1_499, 4_000, 10_000] { + let c = edid::cta_max_luminance_code(nits); + let dec = edid::cta_max_millinits(c); + assert!(dec <= nits as u64 * 1000, "{nits} → {c} decoded {dec}"); + assert!( + dec * 1023 / 1000 >= nits as u64 * 1000, + "{nits} → {c} more than a step low" + ); + } + // Clamps: 0/tiny stays a valid on-wire code (callers gate presence on nits > 0); the + // ceiling saturates at 255. + assert_eq!(edid::cta_max_luminance_code(0), 1); + assert_eq!(edid::cta_max_luminance_code(u32::MAX), 255); + // Min-luminance: 0 = unknown/true black stays 0; a floor brighter than the max clamps. + assert_eq!(edid::cta_min_luminance_code(0, 0x8A), 0); + assert_eq!(edid::cta_min_luminance_code(u32::MAX, 1), 255); + // Round-trip a typical HDR400 panel: max 400 nits / min 0.4 nits. + let max_c = edid::cta_max_luminance_code(400); + let min_c = edid::cta_min_luminance_code(400, max_c); + // decode: L_min = L_max·(cv/255)²/100 — must come back within ~10% of 0.4 nits. + let back = + edid::cta_max_millinits(max_c) * (min_c as u64 * min_c as u64) / (255 * 255) / 100; + assert!((360..=440).contains(&back), "min decoded {back} millinits"); + } + #[test] fn guid_is_not_sudovda() { const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; diff --git a/crates/pf-presenter/src/vk.rs b/crates/pf-presenter/src/vk.rs index c7ce2281..74defd12 100644 --- a/crates/pf-presenter/src/vk.rs +++ b/crates/pf-presenter/src/vk.rs @@ -341,6 +341,11 @@ pub struct Presenter { hdr10_format: Option, /// PQ frames are on screen and the swapchain is in HDR10 mode. hdr_active: bool, + /// One-shot latch: a PQ frame arrived but the surface offers no HDR10 colorspace, so the + /// CSC pass silently tone-maps to SDR. Warned once — the single most useful signal for + /// diagnosing "HDR isn't advertised" (e.g. gamescope's WSI layer invisible in a flatpak + /// sandbox) vs. the host simply not sending PQ. + hdr_downgrade_warned: bool, /// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do). hdr_metadata_d: Option, /// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the @@ -739,6 +744,7 @@ impl Presenter { format, hdr10_format, hdr_active: false, + hdr_downgrade_warned: false, hdr_metadata_d, hdr_meta: None, video_format: vk::Format::R8G8B8A8_UNORM, @@ -1066,6 +1072,19 @@ impl Presenter { FrameInput::D3d11(d) => Some(d.color.is_pq()), }; if let Some(pq) = frame_pq { + // A PQ stream we can only tone-map (no HDR10 surface) is the silent failure behind + // "HDR isn't advertised": the compositor never sees an HDR-committing app. Say so + // once — its presence proves PQ IS arriving and the surface/compositor is the + // blocker (on the Deck: gamescope's WSI layer not visible in the flatpak sandbox); + // its absence, with a plain SDR stream, points back at the host not sending PQ. + if pq && self.hdr10_format.is_none() && !self.hdr_downgrade_warned { + self.hdr_downgrade_warned = true; + tracing::warn!( + "PQ (HDR10) stream tone-mapped to SDR — the surface offers no HDR10 \ + colorspace, so no HDR is committed to the compositor. Under gamescope this \ + usually means the gamescope Vulkan WSI layer is not visible in the sandbox." + ); + } let want = pq && self.hdr10_format.is_some(); if want != self.hdr_active { self.set_hdr_mode(window, want)?; diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 054ef35b..a99d99c6 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -1374,6 +1374,10 @@ pub unsafe extern "C" fn punktfunk_connect_ex7( crate::audio::normalize_channels(audio_channels), video_codecs, preferred_codec, + // No display-HDR-volume parameter in the C ABI yet: Apple/Android clients tone-map + // themselves (EDR / MediaCodec), so the host's EDID defaults are fine there. An `ex8` + // variant can carry it if a passthrough embedder ever needs it. + None, launch, pin, identity, diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index ccaf6e10..82641ea1 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -552,6 +552,11 @@ impl NativeClient { // it emits from these and echoes it in [`NativeClient::codec`]. video_codecs: u8, preferred_codec: u8, + // The client display's HDR colour volume (primaries/white/luminance), read from the OS + // (e.g. DXGI `GetDesc1`) when presenting HDR. The host forwards it into the virtual + // display's EDID so host apps tone-map to the client's real panel; `None` = unknown/SDR + // (the host keeps its built-in EDID defaults). See [`crate::quic::Hello::display_hdr`]. + display_hdr: Option, launch: Option, pin: Option<[u8; 32]>, identity: Option<(String, String)>, @@ -618,6 +623,7 @@ impl NativeClient { audio_channels, video_codecs, preferred_codec, + display_hdr, launch, pin, identity, @@ -1118,6 +1124,33 @@ impl Drop for NativeClient { } } +/// Test/A-B hatch shared by the client shells: `PUNKTFUNK_CLIENT_PEAK_NITS=` synthesizes a +/// display colour volume at that peak (BT.2020 primaries, D65, a 0.005-nit floor, frame-average +/// unknown) for [`Hello::display_hdr`](crate::quic::Hello::display_hdr), overriding whatever the +/// shell read from the OS — so the host-side tone-map target (the virtual display's EDID volume) +/// can be pinned exactly for validation, and shells with no OS display-volume query get a manual +/// knob. `None` when unset/unparsable/zero. +pub fn display_hdr_env_override() -> Option { + let nits: u32 = std::env::var("PUNKTFUNK_CLIENT_PEAK_NITS") + .ok()? + .trim() + .parse() + .ok() + .filter(|&n| n > 0)?; + tracing::info!( + nits, + "PUNKTFUNK_CLIENT_PEAK_NITS: overriding the advertised display volume" + ); + Some(HdrMeta { + display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]], // BT.2020 G, B, R + white_point: [15635, 16450], // D65 + max_display_mastering_luminance: nits.saturating_mul(10_000), + min_display_mastering_luminance: 50, // 0.005 nits + max_cll: 0, + max_fall: 0, + }) +} + struct WorkerArgs { host: String, port: u16, @@ -1129,6 +1162,7 @@ struct WorkerArgs { audio_channels: u8, video_codecs: u8, preferred_codec: u8, + display_hdr: Option, launch: Option, pin: Option<[u8; 32]>, identity: Option<(String, String)>, @@ -1171,6 +1205,7 @@ async fn worker_main(args: WorkerArgs) { audio_channels, video_codecs, preferred_codec, + display_hdr, launch, pin, identity, @@ -1250,6 +1285,9 @@ async fn worker_main(args: WorkerArgs) { // resolves the emitted codec from these and reports it in `Welcome::codec`. video_codecs, preferred_codec, + // The client display's HDR volume → the host's virtual-display EDID (host apps + // tone-map to the client's real panel). `None` = unknown/SDR. + display_hdr, } .encode(), ) diff --git a/crates/punktfunk-core/src/quic/datagram.rs b/crates/punktfunk-core/src/quic/datagram.rs index 1f026b7a..4f7789fa 100644 --- a/crates/punktfunk-core/src/quic/datagram.rs +++ b/crates/punktfunk-core/src/quic/datagram.rs @@ -330,14 +330,16 @@ pub struct HdrMeta { /// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. pub const HDR_META_MAGIC: u8 = 0xCE; -/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32 -/// luminance + 2×u16 CLL/FALL = 29 bytes. -const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4; +/// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32 +/// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which +/// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body). +pub const HDR_META_BODY_LEN: usize = 12 + 4 + 8 + 4; -/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram. -pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec { - let mut b = Vec::with_capacity(HDR_META_LEN); - b.push(HDR_META_MAGIC); +/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + body = 29 bytes. +const HDR_META_LEN: usize = 1 + HDR_META_BODY_LEN; + +/// Append `m`'s [`HDR_META_BODY_LEN`]-byte wire body (LE, no tag byte) to `b`. +pub fn write_hdr_meta_body(m: &HdrMeta, b: &mut Vec) { for p in m.display_primaries.iter() { b.extend_from_slice(&p[0].to_le_bytes()); b.extend_from_slice(&p[1].to_le_bytes()); @@ -348,6 +350,32 @@ pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec { b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes()); b.extend_from_slice(&m.max_cll.to_le_bytes()); b.extend_from_slice(&m.max_fall.to_le_bytes()); +} + +/// Read an [`HdrMeta`] from its wire body (no tag byte). The caller guarantees `b` holds at least +/// [`HDR_META_BODY_LEN`] bytes (both callers slice with an exact-length, bounds-checked `get`). +pub fn read_hdr_meta_body(b: &[u8]) -> HdrMeta { + let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + HdrMeta { + display_primaries: [ + [u16at(0), u16at(2)], + [u16at(4), u16at(6)], + [u16at(8), u16at(10)], + ], + white_point: [u16at(12), u16at(14)], + max_display_mastering_luminance: u32at(16), + min_display_mastering_luminance: u32at(20), + max_cll: u16at(24), + max_fall: u16at(26), + } +} + +/// Encode an [`HdrMeta`] into a [`HDR_META_MAGIC`] datagram. +pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec { + let mut b = Vec::with_capacity(HDR_META_LEN); + b.push(HDR_META_MAGIC); + write_hdr_meta_body(m, &mut b); b } @@ -357,20 +385,7 @@ pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option { if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC { return None; } - let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); - let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); - Some(HdrMeta { - display_primaries: [ - [u16at(1), u16at(3)], - [u16at(5), u16at(7)], - [u16at(9), u16at(11)], - ], - white_point: [u16at(13), u16at(15)], - max_display_mastering_luminance: u32at(17), - min_display_mastering_luminance: u32at(21), - max_cll: u16at(25), - max_fall: u16at(27), - }) + Some(read_hdr_meta_body(&b[1..])) } /// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after diff --git a/crates/punktfunk-core/src/quic/msgs.rs b/crates/punktfunk-core/src/quic/msgs.rs index 1f770ccd..7e16acd7 100644 --- a/crates/punktfunk-core/src/quic/msgs.rs +++ b/crates/punktfunk-core/src/quic/msgs.rs @@ -2,6 +2,7 @@ //! (`CTL_MAGIC` + type byte), including the pairing-ceremony messages. Wire codecs only //! — no transport state. +use super::datagram::HdrMeta; use super::{CTL_MAGIC, MAGIC}; use crate::config::{ CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, @@ -73,6 +74,17 @@ pub struct Hello { /// [`Hello::gamepad`] preference pattern; the resolved codec is echoed in [`Welcome::codec`]. /// Appended after `video_codecs` as a single trailing byte. Omitted by older clients (→ `0`). pub preferred_codec: u8, + /// The client's **display** HDR colour volume — primaries / white point / luminance range in + /// the ST.2086 units of [`HdrMeta`] — read from the client OS (e.g. Windows + /// `IDXGIOutput6::GetDesc1`) when it advertised [`VIDEO_CAP_HDR`]. The host forwards it into + /// the virtual display's EDID (the pf-vdisplay CTA-861.3 HDR static-metadata block), so host + /// apps and the OS tone-map to the CLIENT's real panel instead of the driver's built-in + /// ~1000-nit placeholder — the client can then present the PQ stream untouched. Also echoed + /// back as the session's `0xCE` mastering metadata. Appended after `preferred_codec` as a + /// fixed [`super::datagram::HDR_META_BODY_LEN`]-byte block (the [`HdrMeta`] wire body, no tag), + /// forcing the earlier placeholders. Omitted by older clients / when the client has no HDR + /// display (decodes to `None` — the host keeps its built-in EDID defaults). + pub display_hdr: Option, } /// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. @@ -670,8 +682,9 @@ impl Hello { let ac_present = self.audio_channels != 2; let vcodecs_present = self.video_codecs != 0; let pref_present = self.preferred_codec != 0; + let hdr_present = self.display_hdr.is_some(); let need_placeholders = - self.video_caps != 0 || ac_present || vcodecs_present || pref_present; + self.video_caps != 0 || ac_present || vcodecs_present || pref_present || hdr_present; match (&self.name, &self.launch) { (None, None) if !need_placeholders => {} (name, _) => { @@ -688,21 +701,25 @@ impl Hello { } // video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so // that field lands at a deterministic offset right after it). - if self.video_caps != 0 || ac_present || vcodecs_present || pref_present { + if need_placeholders { b.push(self.video_caps); } // audio_channels: emitted when non-stereo OR a later field follows. - if ac_present || vcodecs_present || pref_present { + if ac_present || vcodecs_present || pref_present || hdr_present { b.push(self.audio_channels); } - // video_codecs: emitted when non-zero OR preferred_codec follows. - if vcodecs_present || pref_present { + // video_codecs: emitted when non-zero OR a later field follows. + if vcodecs_present || pref_present || hdr_present { b.push(self.video_codecs); } - // preferred_codec: single trailing byte. Last field; omitted when `0` (no preference). - if pref_present { + // preferred_codec: emitted when non-zero OR display_hdr follows. + if pref_present || hdr_present { b.push(self.preferred_codec); } + // display_hdr: fixed HDR_META_BODY_LEN-byte HdrMeta body. Last field; omitted when `None`. + if let Some(m) = &self.display_hdr { + super::datagram::write_hdr_meta_body(m, &mut b); + } b } @@ -766,6 +783,11 @@ impl Hello { video_codecs: b.get(tail + 2).copied().unwrap_or(0), // `0` = no preference; the host decides by precedence. preferred_codec: b.get(tail + 3).copied().unwrap_or(0), + // Optional trailing HdrMeta body (fixed length) — absent on an older client / a + // client without an HDR display → `None` (the host keeps its EDID defaults). + display_hdr: b + .get(tail + 4..tail + 4 + super::datagram::HDR_META_BODY_LEN) + .map(super::datagram::read_hdr_meta_body), }) } } diff --git a/crates/punktfunk-core/src/quic/tests.rs b/crates/punktfunk-core/src/quic/tests.rs index 8a9f82f9..889a2b92 100644 --- a/crates/punktfunk-core/src/quic/tests.rs +++ b/crates/punktfunk-core/src/quic/tests.rs @@ -119,6 +119,7 @@ fn codec_negotiation_and_back_compat() { audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders video_codecs: CODEC_H264 | CODEC_HEVC, preferred_codec: CODEC_H264, + display_hdr: None, }; let enc = h.encode(); let dec = Hello::decode(&enc).unwrap(); @@ -234,6 +235,7 @@ fn hello_start_roundtrip() { audio_channels: 2, video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip preferred_codec: CODEC_HEVC, + display_hdr: None, }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -316,6 +318,7 @@ fn hello_welcome_compositor_back_compat() { audio_channels: 2, video_codecs: 0, preferred_codec: 0, + display_hdr: None, }; let enc = h.encode(); assert_eq!(enc.len(), 26); @@ -430,6 +433,7 @@ fn hello_name_roundtrip_and_back_compat() { audio_channels: 2, video_codecs: 0, preferred_codec: 0, + display_hdr: None, }; let enc = base.encode(); assert_eq!( @@ -480,6 +484,7 @@ fn hello_launch_roundtrip_and_back_compat() { audio_channels: 2, video_codecs: 0, preferred_codec: 0, + display_hdr: None, }; // launch alone (no name): a zero-length name placeholder keeps the offset deterministic. let with_launch = Hello { @@ -520,6 +525,70 @@ fn hello_launch_roundtrip_and_back_compat() { assert!(dec.len() <= HELLO_LAUNCH_MAX && dec.starts_with('x')); } +#[test] +fn hello_display_hdr_roundtrip_and_back_compat() { + let base = Hello { + abi_version: 2, + mode: Mode { + width: 3840, + height: 2160, + refresh_hz: 120, + }, + compositor: CompositorPref::Auto, + gamepad: GamepadPref::Auto, + bitrate_kbps: 0, + name: None, + launch: None, + video_caps: VIDEO_CAP_10BIT | VIDEO_CAP_HDR, + audio_channels: 2, + video_codecs: 0, + preferred_codec: 0, + display_hdr: None, + }; + // A real client-panel volume (P3 primaries, 800-nit peak, 0.05-nit floor, 400-nit FALL). + let vol = HdrMeta { + display_primaries: [[13250, 34500], [7500, 3000], [34000, 16000]], // G, B, R + white_point: [15635, 16450], // D65 + max_display_mastering_luminance: 8_000_000, // 800 nits + min_display_mastering_luminance: 500, // 0.05 nits + max_cll: 0, + max_fall: 400, + }; + let with_hdr = Hello { + display_hdr: Some(vol), + ..base.clone() + }; + // Full roundtrip, including the forced placeholders for the earlier trailing fields. + assert_eq!(Hello::decode(&with_hdr.encode()).unwrap(), with_hdr); + // display_hdr alone (every earlier optional at its default) still lands at a deterministic + // offset — the placeholder discipline holds through the whole tail. + let hdr_only = Hello { + video_caps: 0, + display_hdr: Some(vol), + ..base.clone() + }; + assert_eq!(Hello::decode(&hdr_only.encode()).unwrap(), hdr_only); + // An older host reading a display_hdr-bearing Hello ignores the trailing block (its decode + // stops at preferred_codec); a new host reading an older client's Hello gets None. + let enc = with_hdr.encode(); + assert_eq!( + Hello::decode(&enc[..enc.len() - HDR_META_BODY_LEN]).unwrap(), + Hello { + display_hdr: None, + ..with_hdr.clone() + } + ); + assert_eq!(Hello::decode(&base.encode()).unwrap().display_hdr, None); + // A TRUNCATED trailing block (mid-datagram cut) degrades to None, never a partial read. + assert_eq!( + Hello::decode(&enc[..enc.len() - 1]).unwrap().display_hdr, + None + ); + // Exact wire length: 26 bitrate-era bytes + the 6 forced single-byte placeholders + // (name len, launch len, video_caps, audio_channels, video_codecs, preferred_codec) + the body. + assert_eq!(hdr_only.encode().len(), 26 + 6 + HDR_META_BODY_LEN); +} + #[test] fn reconfigure_roundtrip() { let rq = Reconfigure { @@ -786,6 +855,7 @@ fn control_messages_disjoint_from_hello() { audio_channels: 2, video_codecs: 0, preferred_codec: 0, + display_hdr: None, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index c0a479d3..4f0415a0 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use windows::core::{w, Interface, PCWSTR, PWSTR}; use windows::Win32::Foundation::{ DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS, - HANDLE, INVALID_HANDLE_VALUE, LUID, WAIT_OBJECT_0, + HANDLE, INVALID_HANDLE_VALUE, LUID, POINT, WAIT_OBJECT_0, }; use windows::Win32::Graphics::Direct3D11::{ ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, @@ -59,13 +59,14 @@ use windows::Win32::System::Threading::{ use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_MOVE, MOUSEINPUT, }; +use windows::Win32::UI::WindowsAndMessaging::{GetCursorPos, SetCursorPos}; // The frame-transport contract — `SharedHeader` layout, `MAGIC`/`VERSION`/`RING_LEN`, the // `DRV_STATUS_*` codes and the channel-delivery struct — lives in `pf_driver_proto`; both sides // `use` it, so a layout/code drift is a compile error (the proto has `const` size asserts). use frame::{ - SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, - VERSION, + SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, + DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION, }; /// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not @@ -192,16 +193,67 @@ impl Drop for KeyedMutexGuard<'_> { } } -/// Nudge DWM into composing the virtual display: two net-zero 1 px relative mouse moves via -/// `SendInput`. DWM presents a display only when something DIRTIES it — an idle desktop never does, -/// so a freshly-attached ring (session open, or a mid-session ring recreate) can sit at E_PENDING -/// with no first frame even though everything is healthy. pf-vdisplay implements no hardware-cursor -/// plane, so a cursor move is composited into the frame — a guaranteed real present onto the IDD -/// swap-chain (empirically what `punktfunk-probe --input-test` always relied on). Net-zero: the -/// pointer ends exactly where it started; the 1 px round trip is imperceptible, and each event still -/// dirties the cursor layer. Best-effort — injection can be unavailable on the secure desktop, where -/// a fresh compose just happened anyway. -fn kick_dwm_compose() { +/// Nudge DWM into composing THE TARGET virtual display. DWM presents a display only when something +/// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a +/// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is +/// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into +/// the frame — a guaranteed real present onto the IDD swap-chain (empirically what +/// `punktfunk-probe --input-test` always relied on). +/// +/// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display +/// validation: display B's session-open kicks wiggled the cursor on display A and B never composed +/// a first frame. So the kick is per-TARGET: when the cursor already sits inside `target_id`'s +/// desktop region (always true single-display), two net-zero 1 px relative moves (the historical +/// behavior, pointer ends exactly where it started); when it sits on a SIBLING display, jump the +/// cursor to the target's center and straight back (`SetCursorPos` ×2 — each absolute move dirties +/// the cursor layer of the display it lands on, so the target composes at least one frame; the +/// round trip is sub-millisecond and throttled). Best-effort — injection can be unavailable on the +/// secure desktop, where a fresh compose just happened anyway. +fn kick_dwm_compose(target_id: u32) { + // Process-GLOBAL throttle (Stage W3): with N parallel capturers each nudging on its own + // schedule, DWM needs only one dirty per composition window — and the nudge is synthetic INPUT + // (global, user-visible pointer state), so it must not multiply with capturer count. 50 ms + // covers every composition interval we ship (≥ 60 Hz) while staying far under the callers' own + // 600–800 ms per-capturer schedules. + static LAST_KICK: Mutex> = Mutex::new(None); + { + let mut last = LAST_KICK.lock().unwrap(); + let now = Instant::now(); + if last.is_some_and(|t| now.duration_since(t) < Duration::from_millis(50)) { + return; + } + *last = Some(now); + } + // Where is the cursor, and where does the target display live in desktop space? + let mut pos = POINT::default(); + // SAFETY: plain FFI; `pos` is a valid out-param for this synchronous call. + let have_pos = unsafe { GetCursorPos(&mut pos) }.is_ok(); + // SAFETY: `source_desktop_rect` only runs the CCD QueryDisplayConfig FFI over owned local + // buffers; the `Copy` target id crosses by value. + let rect = unsafe { crate::win_display::source_desktop_rect(target_id) }; + if let (true, Some((x, y, w, h))) = (have_pos, rect) { + let inside = pos.x >= x && pos.x < x + w.max(1) && pos.y >= y && pos.y < y + h.max(1); + if !inside { + // The cursor is on a sibling display — a wiggle there dirties the WRONG display. Jump + // to the target's center, DWELL one composition interval, then restore. The dwell is + // load-bearing (proven on-glass, Stage W3): DWM computes dirty state from the CURRENT + // cursor position at the next vsync tick, so a sub-tick jump-and-return is invisible + // and the target never composes — 35 ms covers a 30 Hz tick with margin. The cursor + // visibly leaves the sibling display for those ~2 frames; kicks only fire during THIS + // display's session-open / recovery windows (throttled), so the blip is rare and brief. + // SAFETY: plain FFI; coordinates are plain ints, and the second call restores the + // observed original position. + unsafe { + let _ = SetCursorPos(x + w / 2, y + h / 2); + } + std::thread::sleep(Duration::from_millis(35)); + // SAFETY: as above. + unsafe { + let _ = SetCursorPos(pos.x, pos.y); + } + return; + } + } let mk = |dx: i32| INPUT { r#type: INPUT_MOUSE, Anonymous: INPUT_0 { @@ -1015,6 +1067,13 @@ impl IddPushCapturer { // Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver // reads this into its `ring_format` and drops any surface that doesn't match. (*header).dxgi_format = ring_fmt.0 as u32; + // The ring NAMES its monitor (proto v3, `design/idd-push-security.md` invariant #10) — + // stamped before the magic (below), never changed for the ring's life (a mid-session + // recreate reuses this mapping). The driver refuses to attach a ring naming a different + // monitor, so a stash cross-wire fails closed instead of leaking frames cross-client + // (fail-closed refusal VALIDATED on-glass 2026-07-10 via a fault-injected build: driver + // DRV_STATUS_BIND_FAIL + loud host open failure + sibling stream undisturbed). + (*header).target_id = target.target_id; // Frame-ready event (auto-reset) — UNNAMED, like everything on this channel. let event = CreateEventW(Some(&sa), false, false, PCWSTR::null()) @@ -1118,6 +1177,20 @@ impl IddPushCapturer { /// session open the OS activates the virtual display → DWM composites it → a frame arrives within ~1 s, /// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken. fn wait_for_attach(&self) -> Result<()> { + // Symmetric host-side binding sanity (proto v3 §3.2): OUR header must still name OUR + // monitor. The stamp is ours and nothing legitimate rewrites it, so a mismatch means a + // host-side bug (a stash/capturer cross-wire) — the exact class the driver-side check + // catches from the other end; failing here names the culprit in the same release. + // SAFETY: in-bounds, aligned u32 read of the live, owned shared-header mapping (same access + // pattern as the `driver_status` read below); no reference into the shared region is formed. + let stamped = unsafe { (*self.header).target_id }; + if stamped != self.target_id { + bail!( + "IDD-push: our ring header names target {stamped} but this capturer serves target \ + {} — host-side ring↔monitor cross-wire (bug); failing the open", + self.target_id + ); + } let deadline = Instant::now() + Duration::from_secs(4); // Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle // desktop a perfectly healthy attach sees no first frame (E_PENDING forever) and this gate @@ -1160,12 +1233,23 @@ impl IddPushCapturer { the driver has no ID3D11Device1 to open shared resources)" ); } + if st == DRV_STATUS_BIND_FAIL { + // SAFETY: as above — an in-bounds, aligned `u32` read of a best-effort diagnostic field + // through the owned, live header mapping; no reference into the shared region is formed. + let claimed = unsafe { (*self.header).driver_status_detail }; + bail!( + "IDD-push driver REFUSED the ring↔monitor binding (DRV_STATUS_BIND_FAIL: the \ + delivered ring names target {claimed}, the monitor is {}) — host \ + stash/delivery cross-wire (bug); failing the open loudly (proto v3 §3.2)", + self.target_id + ); + } // Attached AND a frame has been published — the publish token's seq advances past 0. if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 { return Ok(()); } if Instant::now() >= next_kick { - kick_dwm_compose(); + kick_dwm_compose(self.target_id); next_kick = Instant::now() + Duration::from_millis(800); } if Instant::now() > deadline { @@ -1227,6 +1311,11 @@ impl IddPushCapturer { DRV_STATUS_NO_DEVICE1 => { tracing::error!("IDD push: driver has no ID3D11Device1 to open shared resources") } + DRV_STATUS_BIND_FAIL => tracing::error!( + ring_claims_target = detail, + our_target = self.target_id, + "IDD push: driver REFUSED the ring↔monitor binding (host stash cross-wire?)" + ), other => tracing::warn!(other, render_luid, "IDD push: driver reported an unknown status"), } } @@ -1463,7 +1552,7 @@ impl IddPushCapturer { && self.last_kick.elapsed() > Duration::from_millis(800) { self.last_kick = Instant::now(); - kick_dwm_compose(); + kick_dwm_compose(self.target_id); } } // Driver-death watch (the SDR path has no other signal): a dead WUDFHost stops publishing, diff --git a/crates/punktfunk-host/src/encode.rs b/crates/punktfunk-host/src/encode.rs index f8ee8bbc..d4ee11cf 100644 --- a/crates/punktfunk-host/src/encode.rs +++ b/crates/punktfunk-host/src/encode.rs @@ -1054,6 +1054,23 @@ pub fn windows_codec_support() -> CodecSupport { caps } +/// Stage-W3 encoder session-budget seam (`design/windows-parallel-virtual-displays.md` §4.5): +/// whether one more encode session fits the hardware budget — consulted by the display admission +/// before admitting a parallel display, so an unaffordable display is DECLINED instead of silently +/// degrading a live sibling's encode. NVENC is the only backend with hard session caps today +/// (GeForce consumer limit); AMF/QSV equivalents follow the same seam when they grow accounting. +#[cfg(target_os = "windows")] +pub(crate) fn can_open_another_session() -> bool { + #[cfg(feature = "nvenc")] + { + nvenc::can_open_another_session() + } + #[cfg(not(feature = "nvenc"))] + { + true + } +} + // Goal-1 stage 6: GPU/CPU encoders confined to `encode/windows/` (NVENC, native AMF, AMF/QSV // ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the // `crate::encode::*` module names flat. diff --git a/crates/punktfunk-host/src/encode/windows/nvenc.rs b/crates/punktfunk-host/src/encode/windows/nvenc.rs index a1c251b9..d8c4bfe5 100644 --- a/crates/punktfunk-host/src/encode/windows/nvenc.rs +++ b/crates/punktfunk-host/src/encode/windows/nvenc.rs @@ -244,6 +244,45 @@ fn codec_guid(codec: Codec) -> nv::GUID { } } +/// Live NVENC hardware-session units held by THIS host process (a plain session = 1; a forced +/// split-encode session occupies one session per engine = 2–3) — the Stage-W3 encoder budget +/// (`design/windows-parallel-virtual-displays.md` §4.5). Kept in ONE place so admitting a parallel +/// display consults the same accounting every open/teardown maintains; other processes' sessions +/// aren't visible here, but our own consumption is the deterministic part we can enforce +/// fail-closed at admission. +static LIVE_SESSION_UNITS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0); + +/// The NVENC concurrent-session cap to budget against: GeForce (consumer) drivers allow 8 +/// concurrent encode sessions since R550 (pro cards are effectively unlimited). +/// `PUNKTFUNK_NVENC_MAX_SESSIONS` overrides for older drivers / known-different cards. +fn session_cap() -> u32 { + std::env::var("PUNKTFUNK_NVENC_MAX_SESSIONS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(8) +} + +/// Whether one more (plain, non-split) encode session fits the NVENC budget — consulted by +/// admission before admitting a parallel display (`vdisplay::admission`). On a box that never +/// opened NVENC (AMD/Intel/none) the count is 0 and this always passes — the budget seam is +/// NVENC-only until the AMF/QSV equivalents grow their own accounting. +pub(crate) fn can_open_another_session() -> bool { + LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) < session_cap() +} + +/// Session-unit weight of a chosen split-encode mode (one hardware session per engine). +fn split_mode_units(split_mode: u32) -> u32 { + match split_mode { + m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 => 3, + m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32 + || m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 => + { + 2 + } + _ => 1, + } +} + /// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy). /// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until /// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset @@ -407,6 +446,10 @@ pub struct NvencD3d11Encoder { /// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we /// tear down and re-init NVENC against it. init_device: *mut c_void, + /// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 2–3 + /// under forced split-encode — a split session occupies one session per engine). `0` while no + /// session is open; set by `init_session`, returned by `teardown`. + session_units: u32, } // SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`), @@ -469,6 +512,7 @@ impl NvencD3d11Encoder { custom_vbv: false, last_rfi_range: None, init_device: ptr::null_mut(), + session_units: 0, }) } @@ -515,6 +559,9 @@ impl NvencD3d11Encoder { let _ = (api().destroy_bitstream_buffer)(self.encoder, bs); } let _ = (api().destroy_encoder)(self.encoder); + // Return this session's units to the budget (see LIVE_SESSION_UNITS). + LIVE_SESSION_UNITS.fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed); + self.session_units = 0; self.regs.clear(); // drops the texture clones, releasing our refs self.bitstreams.clear(); self.pending.clear(); @@ -1004,6 +1051,11 @@ impl NvencD3d11Encoder { } }; self.encoder = enc; + // Session-budget accounting (Stage W3): record what this open holds so admission can + // decline a parallel display the hardware can't afford. Weighted by the FINAL split + // mode (a split session occupies one hardware session per engine). + self.session_units = split_mode_units(split_mode); + LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed); if self.bitrate_bps < requested_bps { tracing::info!( requested_mbps = requested_bps / 1_000_000, @@ -1661,7 +1713,7 @@ mod tests { fn encode_pattern(chroma: ChromaFormat, path: &str) { const W: u32 = 1280; const H: u32 = 720; - // SAFETY (test-only): straight-line D3D11/DXGI COM calls on one thread; every out-pointer + // SAFETY: (test-only) straight-line D3D11/DXGI COM calls on one thread; every out-pointer // is checked before use; the texture/device outlive the encoder (dropped at scope end). unsafe { let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory"); diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index e15524cb..1e5f8905 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -299,16 +299,19 @@ fn open_gs_virtual_source( // the native session's ring (newest-wins) — each plane could freeze the other. GameStream has // no cooperative stop-flag plumbing, so it registers a flag nobody reads: a LATER session that // preempts this one signals it, waits the 3 s release grace, then force-preempts the monitor — - // this session then fails on capture and tears down cleanly (the intended handover). + // this session then fails on capture and tears down cleanly (the intended handover). GameStream + // is anonymous (no client cert), so it holds the ANONYMOUS slot (0) — GS stays single-display, + // and only a later slot-0 session (another GS/anonymous connect) preempts it. #[cfg(target_os = "windows")] let _idd_setup_guard = matches!( crate::session_plan::CaptureBackend::resolve(), crate::session_plan::CaptureBackend::IddPush ) .then(|| { - crate::vdisplay::manager::vdm().begin_idd_setup(std::sync::Arc::new( - std::sync::atomic::AtomicBool::new(false), - )) + crate::vdisplay::manager::vdm().begin_idd_setup( + 0, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) }); let vout = crate::vdisplay::registry::acquire( &mut vd, diff --git a/crates/punktfunk-host/src/hdr.rs b/crates/punktfunk-host/src/hdr.rs index 8a22a1b7..58d504b3 100644 --- a/crates/punktfunk-host/src/hdr.rs +++ b/crates/punktfunk-host/src/hdr.rs @@ -51,6 +51,19 @@ pub fn hdr_meta_from_display( } } +/// Convert an [`HdrMeta`] display volume into the pf-vdisplay `AddRequest` luminance fields — +/// `(max nits, max frame-average nits, min MILLI-nits)` — which the driver codes into the virtual +/// monitor's EDID CTA-861.3 HDR block. Pure unit conversion: mastering luminance is 0.0001 cd/m² +/// (so nits = /10 000, milli-nits = /10); MaxFALL is already nits and doubles as the display's +/// frame-average ceiling. +pub fn vdisplay_luminance_fields(m: &HdrMeta) -> (u32, u32, u32) { + ( + m.max_display_mastering_luminance / 10_000, + m.max_fall as u32, + m.min_display_mastering_luminance / 10, + ) +} + /// A generic HDR10 default (BT.2020 primaries, D65 white, 1000-nit mastering, MaxCLL 1000 / /// MaxFALL 400) — the baseline a host sends until it reads the source display's real mastering /// metadata, and the values clients used to hardcode. @@ -150,6 +163,25 @@ mod tests { assert_eq!(p, [0x03, 0xE8, 0x01, 0x90]); // 1000, 400 big-endian } + #[test] + fn vdisplay_luminance_fields_convert_units() { + // An 800-nit / 0.05-nit panel with a 400-nit frame-average ceiling: the AddRequest fields + // come out as whole nits / nits / MILLI-nits. + let m = hdr_meta_from_display( + (0.680, 0.320), + (0.265, 0.690), + (0.150, 0.060), + (0.3127, 0.3290), + 800.0, + 0.05, + 0, + 400, + ); + assert_eq!(vdisplay_luminance_fields(&m), (800, 400, 50)); + // The all-zero (unknown) volume stays all-zero — the driver keeps its EDID defaults. + assert_eq!(vdisplay_luminance_fields(&HdrMeta::default()), (0, 0, 0)); + } + #[test] fn clamps_out_of_range() { let m = hdr_meta_from_display( diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 7a917d20..b8978a19 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1370,9 +1370,15 @@ async fn serve_session( // GetDesc1) as soon as capture starts and re-sends it on keyframes; the client applies the // latest it receives. This baseline covers the synthetic source and the pre-capture gap. if welcome.color.is_hdr() { - let meta = crate::hdr::generic_hdr10(); + // Prefer the CLIENT's own display volume (Hello::display_hdr): the virtual display's EDID + // now advertises it, so host apps tone-map to exactly that volume — echoing it here keeps + // the mastering metadata honest end-to-end. Generic HDR10 only for older clients. + let meta = hello.display_hdr.unwrap_or_else(crate::hdr::generic_hdr10); let _ = conn.send_datagram(punktfunk_core::quic::encode_hdr_meta_datagram(&meta).into()); - tracing::info!("sent HDR10 static metadata (0xCE; generic baseline)"); + tracing::info!( + client_volume = hello.display_hdr.is_some(), + "sent HDR10 static metadata (0xCE baseline)" + ); } // Test hook (synthetic source only): a scripted feedback burst on the host→client @@ -1445,6 +1451,10 @@ async fn serve_session( }; let stop_stream = stop.clone(); let quit_stream = quit.clone(); + // The client display's HDR volume (Hello): the virtual display's EDID advertises it (host apps + // tone-map to the client's real panel) and the 0xCE mastering metadata echoes it. `None` = + // older client / no HDR display → the built-in defaults everywhere. + let client_hdr = hello.display_hdr; let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream // Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All @@ -1533,6 +1543,7 @@ async fn serve_session( stats: stats_dp, client_label, launch: launch_for_dp, + client_hdr, }) } } @@ -3178,6 +3189,11 @@ struct SessionContext { /// command already resolved against the host's own library — nested into gamescope's bare spawn /// via `set_launch_command`, or spawned into the live session once capture is up. launch: Option, + /// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR). + /// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block, + /// so host apps tone-map to the client's real panel) and preferred over the generic baseline + /// for the 0xCE mastering metadata. + client_hdr: Option, } fn virtual_stream(ctx: SessionContext) -> Result<()> { @@ -3217,6 +3233,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { stats, client_label, launch, + client_hdr, } = ctx; tracing::info!( compositor = compositor.id(), @@ -3234,6 +3251,10 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). vd.set_client_identity(endpoint::peer_fingerprint(&conn)); + // The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID CTA HDR + // block (pf-vdisplay), so host apps + the OS tone-map to the client's real panel instead of the + // driver's built-in ~1000-nit placeholder. No-op on Linux backends and for older/SDR clients. + vd.set_client_hdr(client_hdr); // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints — // the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop" // (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead @@ -3253,9 +3274,17 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // register THIS session's stop. The returned guard holds the setup lock across the pipeline build; // dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created // (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation. + // SLOT-scoped (Stage W1): the preempt targets only a prior session holding THIS client's slot — + // a different identity's session is an admission question, never a preempt. #[cfg(target_os = "windows")] - let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) - .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); + let _idd_setup_guard = + (plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| { + let slot = crate::vdisplay::manager::slot_id_for( + endpoint::peer_fingerprint(&conn), + (mode.width, mode.height), + ); + crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) + }); let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) = build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). @@ -3791,11 +3820,15 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { diag_repeat = 0; diag_at = std::time::Instant::now(); } - // The source's static HDR mastering metadata (Windows GetDesc1; None on Linux/SDR) is the - // single source of truth: hand it to the encoder (in-band SEI on keyframes) and, when it - // changes, to the client (0xCE). Re-sent on each keyframe below so a dropped best-effort - // datagram converges within a GOP. - let hdr_meta = capturer.hdr_meta(); + // The source's static HDR mastering metadata is the single source of truth: hand it to the + // encoder (in-band SEI on keyframes) and, when it changes, to the client (0xCE). Re-sent on + // each keyframe below so a dropped best-effort datagram converges within a GOP. PRESENCE is + // the capturer's call (Some iff the virtual display is in HDR mode); the VALUE prefers the + // client's own display volume when it sent one — the virtual display's EDID advertises + // exactly that volume, so host apps already tone-mapped the content into it and the honest + // mastering description IS the client's panel. (The IDD capturer only knows the generic + // baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.) + let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m)); enc.set_hdr_meta(hdr_meta); let mut resend_meta = hdr_meta != last_hdr_meta; if resend_meta { @@ -4840,6 +4873,7 @@ mod tests { 2, // audio_channels (stereo) 0, // video_codecs (0 → HEVC-only) 0, // preferred_codec (auto) + None, // display_hdr None, // launch None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client Some((cert, key)), @@ -4906,6 +4940,7 @@ mod tests { 2, // audio_channels (stereo) 0, // video_codecs 0, // preferred_codec + None, // display_hdr None, // launch None, None, @@ -4934,6 +4969,7 @@ mod tests { 2, // audio_channels (stereo) 0, // video_codecs 0, // preferred_codec + None, // display_hdr None, // launch Some(host_fp), Some((cert.clone(), key.clone())), diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 8ebda102..b4963f40 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -132,6 +132,15 @@ pub trait VirtualDisplay: Send { /// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach; /// Linux backends get the flag through `registry::acquire`). fn set_quit_flag(&mut self, _quit: std::sync::Arc) {} + /// Hand the backend the CLIENT display's HDR colour volume (`Hello::display_hdr` — primaries / + /// white point / luminance range as reported by the client OS), so a freshly created virtual + /// output can advertise the client's REAL panel in its EDID (pf-vdisplay codes the luminance + /// into the CTA-861.3 HDR static-metadata block) — host apps and the OS then tone-map to the + /// panel the stream actually lands on instead of a built-in placeholder volume. Carried on the + /// backend instance; set once before [`create`](Self::create). `None` = unknown/SDR client → + /// the backend's default EDID. Default: no-op — only the Windows pf-vdisplay backend can mint + /// per-monitor EDIDs today (the Linux compositors' virtual outputs take no EDID from us). + fn set_client_hdr(&mut self, _hdr: Option) {} /// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — /// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The /// registry reads it right after `create` to key the display's group **arrangement** (manual diff --git a/crates/punktfunk-host/src/vdisplay/admission.rs b/crates/punktfunk-host/src/vdisplay/admission.rs index bf318b38..21771728 100644 --- a/crates/punktfunk-host/src/vdisplay/admission.rs +++ b/crates/punktfunk-host/src/vdisplay/admission.rs @@ -92,27 +92,57 @@ pub fn decide( /// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when /// unconfigured), with the **Windows default applied**. On Windows `separate` — including the -/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the -/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes -/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd -/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins. -/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths. +/// unconfigured default — still resolves to **`reject`** UNLESS the Stage-W3 validation hatch +/// `PUNKTFUNK_WIN_SEPARATE=1` is set (`design/windows-parallel-virtual-displays.md` §4.3 — the +/// default flips to real `separate` in W5, after the on-glass matrix is green). +/// +/// The historical `reject` override guarded against a real wedge: two concurrent Windows sessions +/// both drove the SAME pf-vdisplay monitor's single-capturer IDD-push channel +/// ("newest-delivery-wins"), which froze the live client and could wedge the driver. With the +/// manager's slot map (Stage W1) that wedge is structurally impossible — a second identity gets its +/// OWN slot → own monitor → own sealed ring — so the override is now a validation-soak guard, not a +/// correctness one. `join`/`steal` stay as explicit opt-ins. Linux keeps `separate` (real +/// multi-view). Shared by the native + GameStream admission paths. pub fn effective_conflict() -> ModeConflict { let conflict = policy::prefs() .configured_effective() .map(|e| e.mode_conflict) .unwrap_or(ModeConflict::Separate); #[cfg(windows)] - if matches!(conflict, ModeConflict::Separate) { + if matches!(conflict, ModeConflict::Separate) + && !std::env::var("PUNKTFUNK_WIN_SEPARATE").is_ok_and(|v| v == "1") + { return ModeConflict::Reject; } conflict } /// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`] -/// against the live set. +/// against the live set, then — when a SECOND display would actually be created (`Separate` with +/// other clients live, Windows) — the Stage-W3 resource budgets: `max_displays` across the manager's +/// live/kept slots, and the encoder's session headroom. Fail-closed at admission +/// (`design/windows-parallel-virtual-displays.md` §2.5): a display we can't afford is DECLINED here, +/// never admitted-then-degrading a live sibling. pub fn admit(req_identity: Option<[u8; 32]>) -> Admission { - decide(effective_conflict(), req_identity, &table().lock().unwrap()) + let live = table().lock().unwrap(); + let decision = decide(effective_conflict(), req_identity, &live); + #[cfg(windows)] + if matches!(decision, Admission::Separate) && !live.is_empty() { + let max = policy::prefs().get().effective().max_displays; + let slots = super::manager::snapshot().len() as u32; + if slots >= max { + return Admission::Reject(format!( + "host display budget exhausted: {slots} display(s) live/kept, max_displays = {max}" + )); + } + if !crate::encode::can_open_another_session() { + return Admission::Reject( + "host encoder budget exhausted: no NVENC session headroom for another display" + .to_string(), + ); + } + } + decision } /// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client diff --git a/crates/punktfunk-host/src/vdisplay/registry.rs b/crates/punktfunk-host/src/vdisplay/registry.rs index 19e45c7c..5ce780c9 100644 --- a/crates/punktfunk-host/src/vdisplay/registry.rs +++ b/crates/punktfunk-host/src/vdisplay/registry.rs @@ -108,11 +108,13 @@ pub fn acquire( pub fn snapshot() -> Snapshot { #[cfg(target_os = "windows")] { - // Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0, - // origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not - // surfaced here yet. + // Windows slots (Stage W1): one group — the shared desktop — with the manager's slot list in + // acquire order (`display_index`), each at its group-layout position. `identity_slot` is the + // slot key (`None` for the anonymous slot 0). let displays = super::manager::snapshot() - .map(|i| DisplayInfo { + .into_iter() + .enumerate() + .map(|(idx, i)| DisplayInfo { slot: i.gen, backend: i.backend.to_string(), mode: i.mode, @@ -121,12 +123,11 @@ pub fn snapshot() -> Snapshot { sessions: i.sessions, client: None, group: 1, - display_index: 0, - position: (0, 0), - identity_slot: None, + display_index: idx as u32, + position: i.position, + identity_slot: (i.slot_id != 0).then_some(i.slot_id), topology: topology_str(), }) - .into_iter() .collect(); Snapshot { displays } } @@ -149,10 +150,9 @@ pub fn snapshot() -> Snapshot { pub fn release(slot: Option) -> usize { #[cfg(target_os = "windows")] { - // Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one - // lingering monitor if present. (Multi-monitor gives `slot` meaning later.) - let _ = slot; - usize::from(super::manager::force_release()) + // Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp + // ([`DisplayInfo::slot`]); `None` releases every kept one. + super::manager::force_release(slot) } #[cfg(target_os = "linux")] { diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 0ee64a52..8e5ac2a3 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -16,6 +16,7 @@ // Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). #![deny(clippy::undocumented_unsafe_blocks)] +use std::collections::BTreeMap; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Once, OnceLock}; @@ -46,12 +47,14 @@ pub(crate) enum MonitorKey { } /// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID + the -/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target). +/// driver's WUDFHost pid (the sealed frame channel's handle-duplication target) + the monitor id the +/// driver actually resolved (the per-client stable id when honored; diagnostics on the slot). pub(crate) struct AddedMonitor { pub key: MonitorKey, pub target_id: u32, pub luid: LUID, pub wudf_pid: u32, + pub resolved_monitor_id: u32, } /// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. @@ -70,10 +73,11 @@ pub(crate) trait VdisplayDriver: Send + Sync { /// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment. unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>; /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and - /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). Returns the REMOVE - /// key + target id + the IddCx DISPLAY adapter LUID from the ADD reply - /// (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the driver reports its render - /// adapter only in the shared frame header). + /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr` + /// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the + /// driver's built-in defaults). Returns the REMOVE key + target id + the IddCx DISPLAY adapter + /// LUID from the ADD reply (`IDARG_OUT_MONITORARRIVAL.OsAdapterLuid` — NOT the render GPU; the + /// driver reports its render adapter only in the shared frame header). /// /// # Safety /// `dev` must be the live control handle from [`open`](Self::open). @@ -83,6 +87,7 @@ pub(crate) trait VdisplayDriver: Send + Sync { mode: Mode, render_luid: Option, preferred_monitor_id: u32, + client_hdr: Option, ) -> Result; /// REMOVE the monitor identified by `key`. /// @@ -97,8 +102,13 @@ pub(crate) trait VdisplayDriver: Send + Sync { } /// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by -/// any session). No `Drop` impl — [`teardown`](VirtualDisplayManager::teardown) must be called so the -/// REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor). +/// any session). No `Drop` impl — [`teardown_removed`](VirtualDisplayManager::teardown_removed) must be +/// called so the REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor). +/// +/// Since the Stage-W1 slot map, what is GROUP-scoped no longer lives here: the CCD `SavedConfig`, +/// `ddc_panels_off` and `pnp_disabled` moved to [`GroupState`] (first-in captures, last-out restores — +/// `design/display-management.md` §6.1), and the per-monitor watchdog pinger became ONE device-level +/// pinger (any IOCTL bumps the driver watchdog; per-monitor pingers were redundancy, not correctness). struct Monitor { key: MonitorKey, target_id: u32, @@ -111,19 +121,18 @@ struct Monitor { /// on — [`warn_if_pick_moved`] compares the CURRENT pick against it. render_pin: Option, /// The driver's WUDFHost pid (from the ADD reply) — carried into [`WinCaptureTarget`] so the - /// IDD-push capturer knows where to duplicate the sealed frame channel's handles. + /// IDD-push capturer knows where to duplicate the sealed frame channel's handles. The SAME + /// process for every parallel monitor (one devnode → one WUDFHost hosts all publishers), which + /// is why WUDFHost death is ALL-slot shared fate. wudf_pid: u32, gdi_name: Option, mode: Mode, - stop: Arc, - pinger: Option>, - ccd_saved: Option, - /// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at this monitor's - /// isolate (`ddc_power_off` policy axis) — teardown wakes them after the CCD restore iff > 0. - ddc_panels_off: u32, - /// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled - /// at this monitor's isolate — teardown re-enables them BEFORE the CCD restore. - pnp_disabled: Vec, + /// The monitor id the driver actually resolved (the EDID serial / ConnectorIndex) — equals the + /// slot key when the per-client preference was honored, or the auto-allocated id (diagnostics). + resolved_monitor_id: u32, + /// This monitor's desktop-space origin from the group layout (`(0,0)` until a multi-slot + /// arrangement places it) — reported via [`ManagedInfo`]. + position: (i32, i32), /// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op). gen: u64, } @@ -142,8 +151,9 @@ impl Monitor { } } -enum MgrState { - Idle, +/// One slot's state — today's per-monitor machine, per entry (an Idle slot is simply absent from the +/// map; `design/windows-parallel-virtual-displays.md` §4.1 / Stage W1). +enum SlotState { Active { mon: Monitor, refs: u32, @@ -162,6 +172,62 @@ enum MgrState { }, } +impl SlotState { + fn mon(&self) -> &Monitor { + match self { + SlotState::Active { mon, .. } + | SlotState::Lingering { mon, .. } + | SlotState::Pinned { mon } => mon, + } + } +} + +/// Group-scoped topology state (ONE group on Windows — the shared desktop, +/// `design/display-management.md` §6.1): captured by the FIRST slot's isolate, restored when the LAST +/// member drops. Per-monitor restore would flash the physical panels back between sibling sessions. +#[derive(Default)] +struct GroupState { + /// The pre-isolate active config (first slot's snapshot) — teardown restores it on last-member + /// drop. `Some` also marks "an exclusive isolate is live", so slot add/remove re-issues the + /// isolate with the grown/shrunk managed set. + ccd_saved: Option, + /// How many physical panels acknowledged the EXPERIMENTAL DDC/CI off command at the group's + /// first isolate (`ddc_power_off` policy axis) — last-member teardown wakes them after the CCD + /// restore iff > 0. + ddc_panels_off: u32, + /// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at + /// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore. + pnp_disabled: Vec, +} + +/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every +/// group mutation happens on a slot transition, so splitting them would only invite lock-order bugs. +#[derive(Default)] +struct MgrInner { + /// Live/kept slots, keyed by the SLOT id: the client's stable identity slot (`1..=15`, + /// `identity::resolve_slot`) — what is stable per client across reconnects — or `0` for + /// anonymous/GameStream sessions (at most one at a time, exactly the pre-slot-map semantics; an + /// anonymous re-acquire has no identity to find any other slot by). + slots: BTreeMap, + group: GroupState, +} + +impl MgrInner { + /// Live target ids in acquire (gen) order — the CCD isolate keep-set + the layout member order. + fn target_ids(&self) -> Vec { + let mut mons: Vec<&Monitor> = self.slots.values().map(SlotState::mon).collect(); + mons.sort_by_key(|m| m.gen); + mons.iter().map(|m| m.target_id).collect() + } +} + +/// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver +/// watchdog, so one thread serves N monitors). +struct Pinger { + stop: Arc, + thread: JoinHandle<()>, +} + /// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the /// cached handle (every IOCTL fails with a gone-class code forever), so such a failure RETIRES it and /// the next [`VirtualDisplayManager::ensure_device`] reopens the (new) device interface, re-running @@ -190,14 +256,19 @@ pub(crate) struct VirtualDisplayManager { watchdog_s: AtomicU32, /// Monotonic lease-generation counter (was the `MON_GEN` global). gen: AtomicU64, - state: Mutex, - /// Serializes IDD-push session SETUP (preempt + monitor create) so a reconnect flood can't run - /// concurrent monitor create/teardown — held by the session across the pipeline build (was the - /// `IDD_SETUP_LOCK` global in `punktfunk1`). + state: Mutex, + /// Serializes IDD-push session SETUP (preempt + monitor create) — MANAGER-WIDE even with slots: + /// monitor create/teardown stays serialized (the 400 ms async-departure settle and the IddCx + /// slot-budget wedge both want zero concurrent ADD/REMOVE). Held by the session across the + /// pipeline build (was the `IDD_SETUP_LOCK` global in `punktfunk1`). setup_lock: Mutex<()>, - /// The current IDD-push session's stop flag; a new connection signals the prior one to release its - /// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`). - idd_session_stop: Mutex>>, + /// Per-SLOT IDD-push session stop flags: a new connection signals only the stop of a session + /// holding *that identity's* slot (the same-client zombie-reconnect preempt, slot-scoped since + /// Stage W1 — a different identity is an ADMISSION question, never a preempt). Entries persist + /// per slot (bounded at 16); signaling an ended session's flag is harmless. + idd_session_stops: Mutex>>, + /// The device-level watchdog [`Pinger`], running while any slot lives. + pinger: Mutex>, // The per-client stable monitor-id map is now the process-wide `super::identity::global()` // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx @@ -214,9 +285,10 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa device: Mutex::new(DeviceSlot::default()), watchdog_s: AtomicU32::new(3), gen: AtomicU64::new(1), - state: Mutex::new(MgrState::Idle), + state: Mutex::new(MgrInner::default()), setup_lock: Mutex::new(()), - idd_session_stop: Mutex::new(None), + idd_session_stops: Mutex::new(std::collections::HashMap::new()), + pinger: Mutex::new(None), }) } @@ -401,49 +473,54 @@ impl VirtualDisplayManager { self.ensure_device().map(|_| ()) } - /// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one - /// (refcount++), reuse a lingering one, or create one. `client_fp` (the connecting client's cert - /// fingerprint; `None` = anonymous/GameStream) gives a freshly CREATED monitor a STABLE per-client id - /// (so Windows reapplies that client's saved per-monitor config); JOIN and lingering-reuse keep the - /// existing monitor's id. The returned [`MonitorLease`] releases the refcount on drop. + /// Acquire this client's slot for a new session: preempt-recreate under IDD-push, join its live + /// monitor (refcount++), or create one. `client_fp` (the connecting client's cert fingerprint; + /// `None` = anonymous/GameStream) keys the SLOT (`slot_id_for`) and gives a freshly CREATED + /// monitor the client's STABLE per-client id (so Windows reapplies its saved per-monitor + /// config). One live slot behaves exactly like the pre-slot-map singleton; a second identity + /// gets its OWN slot → own monitor → own sealed ring (Stage W1/W3). The returned + /// [`MonitorLease`] releases the slot's refcount on drop. pub(crate) fn acquire( &'static self, mode: Mode, client_fp: Option<[u8; 32]>, + client_hdr: Option, quit: Option>, ) -> Result { self.ensure_linger_timer(); - let mut state = self.state.lock().unwrap(); + let slot = slot_id_for(client_fp, (mode.width, mode.height)); + let mut inner = self.state.lock().unwrap(); let dev = self.ensure_device()?; - // IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client - // RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it - // hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and - // create a fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. + // IDD-push: a new connection while THIS SLOT's monitor is kept (LINGERING or PINNED) is a + // single-client RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is + // DEAD, so reusing it hands a black screen — PREEMPT: tear the kept monitor down and create a + // fresh one. The old session's lease is gen-stamped, so its later drop is a no-op. A SIBLING + // slot's kept monitor is never touched — that's another client's display. // // ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the // build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a - // concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn - // REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool - // and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD). - if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) { - let taken = match std::mem::replace(&mut *state, MgrState::Idle) { - MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon), - other => { - *state = other; - None - } - }; - if let Some(mon) = taken { + // concurrent same-client session, NOT a reconnect. Preempting Active would tear a live session + // down AND churn REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts + // the IddCx slot pool and wedges ADD at 0x80070490. Active falls through to the JOIN path + // below (refcount++, no ADD). + if matches!( + inner.slots.get(&slot), + Some(SlotState::Lingering { .. } | SlotState::Pinned { .. }) + ) { + if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) = + inner.slots.remove(&slot) + { tracing::info!( + slot, old_target = mon.target_id, "IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one" ); - // SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value - // `ensure_device()` returned above (cached handles are never closed — a dead one is - // retired, kept alive; see `DeviceSlot`). `mon` was moved out of the prior `Lingering` - // state by `mem::replace`, so it is exclusively owned here — no aliasing. - unsafe { self.teardown(dev, mon) }; + // SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the + // value `ensure_device()` returned above (cached handles are never closed — a dead one + // is retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it + // is exclusively owned here — no aliasing. + unsafe { self.teardown_removed(dev, &mut inner, mon) }; // Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back // REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn. thread::sleep(Duration::from_millis(400)); @@ -452,102 +529,123 @@ impl VirtualDisplayManager { // An ACTIVE monitor whose WUDFHost has EXITED is dead driver-side (driver crash / upgrade): // the capturer's driver-death watch failed its session, and that session's in-place rebuild - // re-acquires here while its old lease is STILL held — so the state is Active. Joining would + // re-acquires here while its old lease is STILL held — so the slot is Active. Joining would // hand the rebuild the dead monitor's target (stale wudf_pid) and starve it to the rebuild // budget. Preempt instead: best-effort teardown (REMOVE fails harmlessly on a dead/retired // device) and fall through to a fresh create on the auto-restarted device. Held leases are - // gen-stamped, so their eventual release is a no-op. - if matches!(&*state, MgrState::Active { mon, .. } if !wudf_alive(mon.wudf_pid)) { - if let MgrState::Active { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle) { + // gen-stamped, so their eventual release is a no-op. ONE WUDFHost hosts every slot's + // publisher, so its death is ALL-slot shared fate — but each sibling's session fails through + // its own capturer watch and rebuilds through this same path; no cross-slot teardown here. + if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid)) + { + if let Some(SlotState::Active { mon, .. }) = inner.slots.remove(&slot) { tracing::warn!( + slot, old_target = mon.target_id, wudf_pid = mon.wudf_pid, "virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating" ); - // SAFETY: `teardown` requires a valid control handle; `dev` is the value + // SAFETY: `teardown_removed` requires a valid control handle; `dev` is the value // `ensure_device()` returned above (cached handles are never closed — a dead one is - // retired, kept alive; see `DeviceSlot`). `mon` was moved out of the replaced state, - // so it is exclusively owned here — no aliasing. - unsafe { self.teardown(dev, mon) }; + // retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it + // is exclusively owned here — no aliasing. + unsafe { self.teardown_removed(dev, &mut inner, mon) }; // Same async-departure settle as the reconnect preempt above. thread::sleep(Duration::from_millis(400)); } } - // A live monitor already exists — join it (refcount++). Covers concurrent sessions AND the - // build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken while the old is - // still held). Reconfigure the shared monitor if the requested mode differs. - if let MgrState::Active { mon, refs } = &mut *state { + // This slot already has a live monitor — join it (refcount++). Covers same-client concurrent + // sessions AND the build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken + // while the old is still held). Reconfigure the shared monitor if the requested mode differs. + if let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) { *refs += 1; - if mon.mode != mode { + let reconfigured = mon.mode != mode; + if reconfigured { // SAFETY: `reconfigure` only manipulates the live display topology via the CCD/GDI - // helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into the current - // `Active` state, held under the `state` lock, so nothing else reconfigures it concurrently. + // helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into this slot's + // `Active` state, held under the `state` lock, so nothing else reconfigures it + // concurrently. unsafe { self.reconfigure(mon, mode) }; } tracing::info!( + slot, refs = *refs, backend = self.driver.name(), "virtual monitor reused (concurrent / reconfigure session)" ); warn_if_pick_moved(mon); - return Ok(self.output_for(mon, quit)); + let out = self.output_for(slot, mon, quit); + if reconfigured { + // A mode change alters this member's width — re-arrange the group so auto-row + // siblings don't overlap the resized display (no-op for a single member). + self.apply_group_layout(&mut inner); + } + return Ok(out); } - // Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a - // kept Lingering/Pinned monitor was already preempted → Idle above; this arm is the defensive - // reuse path if a race left one here — it must stay exhaustive over `Pinned` regardless.) - let mon = match std::mem::replace(&mut *state, MgrState::Idle) { - MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => { + // Display budget (Stage W3): a display we can't afford is DECLINED at admission + // (`max_displays` across Active+Lingering+Pinned slots; the identity-slot ceiling of 15 is + // the hard limit behind it) — this is the fail-closed backstop for a session that got past + // admission anyway. One live slot can never trip it (max_displays >= 1). + let max = crate::vdisplay::policy::prefs() + .get() + .effective() + .max_displays; + if inner.slots.len() as u32 >= max { + anyhow::bail!( + "display budget exhausted: {} display(s) live/kept, max_displays = {max} — freeing \ + one (session end, linger expiry, or /display/release) admits the next", + inner.slots.len() + ); + } + + // The slot is empty: create a fresh monitor for it. + // SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the handle + // `ensure_device()` returned above (cached handles are never closed — a dead one is retired, + // kept alive; see `DeviceSlot`), and we hold the `state` lock. + let mon = match unsafe { self.create_monitor(dev, mode, slot, client_hdr, &mut inner) } { + // The cached device died under us (driver upgrade / WUDFHost restart, detected only + // now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and + // retry ONCE so the reconnect-after-driver-restart succeeds first try instead of + // burning one failed session per restart. + Err(e) if is_device_gone(&e) => { + self.invalidate_device(&e); + let dev = self.ensure_device()?; tracing::info!( - backend = self.driver.name(), - "virtual monitor reused (reconnect to a kept monitor)" + "virtual-display control device reopened — retrying the monitor create" ); - warn_if_pick_moved(&mon); - if mon.mode != mode { - // SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live - // display topology. `mon` is the local monitor just moved out of the `Lingering` - // state (sole owner), and we hold the `state` lock — no concurrent reconfigure. - unsafe { self.reconfigure(&mut mon, mode) }; - } - mon + // SAFETY: as above — `dev` is the handle the reopening `ensure_device` just + // returned, and the `state` lock is still held. + unsafe { self.create_monitor(dev, mode, slot, client_hdr, &mut inner)? } } - // SAFETY: `create_monitor` requires `dev` to be a valid control handle; `dev` is the - // handle `ensure_device()` returned above (cached handles are never closed — a dead one - // is retired, kept alive; see `DeviceSlot`), and we hold the `state` lock. - MgrState::Idle => match unsafe { self.create_monitor(dev, mode, client_fp) } { - // The cached device died under us (driver upgrade / WUDFHost restart, detected only - // now — e.g. the host sat idle past the pinger-less window). Retire it, reopen, and - // retry ONCE so the reconnect-after-driver-restart succeeds first try instead of - // burning one failed session per restart. - Err(e) if is_device_gone(&e) => { - self.invalidate_device(&e); - let dev = self.ensure_device()?; - tracing::info!( - "virtual-display control device reopened — retrying the monitor create" - ); - // SAFETY: as above — `dev` is the handle the reopening `ensure_device` just - // returned, and the `state` lock is still held. - unsafe { self.create_monitor(dev, mode, client_fp)? } - } - r => r?, - }, - MgrState::Active { .. } => unreachable!("handled above"), + r => r?, }; - let out = self.output_for(&mon, quit); - *state = MgrState::Active { mon, refs: 1 }; + let out = self.output_for(slot, &mon, quit); + inner.slots.insert(slot, SlotState::Active { mon, refs: 1 }); + // Multi-slot group layout (§6.2): arrange the live members (auto-row / manual pins) and + // commit their desktop origins in one CCD apply. A single member sits at the origin and this + // no-ops — the single-display path issues no positioning at all. + self.apply_group_layout(&mut inner); Ok(out) } - /// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`. - /// `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see [`Self::release`]). - fn output_for(&'static self, mon: &Monitor, quit: Option>) -> VirtualOutput { + /// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for + /// `mon` in `slot`. `quit` is the session's deliberate-quit flag, read by the lease `Drop` (see + /// [`Self::release`]). + fn output_for( + &'static self, + slot: u32, + mon: &Monitor, + quit: Option>, + ) -> VirtualOutput { VirtualOutput { node_id: 0, preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)), win_capture: mon.target(), keepalive: Box::new(MonitorLease { mgr: self, + slot, gen: mon.gen, quit, }), @@ -557,45 +655,19 @@ impl VirtualDisplayManager { } } - /// Create a fresh monitor at `mode`: ADD via the driver (pinning the discrete render GPU under the - /// usual conditions), start the watchdog pinger, resolve the GDI name, force the mode + isolate to a - /// sole composited display. - /// - /// # Safety - /// `dev` must be the live control handle. - unsafe fn create_monitor( - &'static self, - dev: HANDLE, - mode: Mode, - client_fp: Option<[u8; 32]>, - ) -> Result { - // Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved - // per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver - // auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy - // picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior). - let preferred_id = super::identity::resolve_slot( - client_fp, - (mode.width, mode.height), - crate::vdisplay::policy::Identity::PerClient, - ) - .unwrap_or(0); - let render_pin = resolve_render_pin(); - // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control - // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. - // `render_pin` is an `Option` by value (plain `Copy`), so no borrowed memory - // crosses the call. - let added = unsafe { - self.driver - .add_monitor(dev, mode, render_pin, preferred_id)? - }; - - // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. - // The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle. + /// Start the device-level watchdog pinger if it isn't running (first slot), so the driver's + /// host-gone watchdog stays satisfied while ANY monitor lives. One thread serves every slot — + /// any IOCTL bumps the watchdog, so per-monitor pingers were redundancy, not correctness. + fn ensure_pinger(&'static self) { + let mut guard = self.pinger.lock().unwrap(); + if guard.is_some() { + return; + } let stop = Arc::new(AtomicBool::new(false)); let interval = Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3); let stop_t = stop.clone(); - let pinger = thread::spawn(move || { + let thread = thread::spawn(move || { let mut warned = false; while !stop_t.load(Ordering::Relaxed) { if let Some(h) = vdm().device_handle() { @@ -610,12 +682,14 @@ impl VirtualDisplayManager { Err(e) if is_device_gone(&e) => { // The device itself is gone (driver upgrade / WUDFHost restart) — pings // can only keep failing on this handle. Retire it so the next session's - // `ensure_device` reopens; this monitor is already dead driver-side. + // `ensure_device` reopens; the monitors are already dead driver-side. vdm().invalidate_device(&e); } Err(e) => { if !warned { - tracing::warn!("virtual-display keepalive PING failed (control handle lost?): {e:#}"); + tracing::warn!( + "virtual-display keepalive PING failed (control handle lost?): {e:#}" + ); warned = true; } } @@ -624,6 +698,102 @@ impl VirtualDisplayManager { thread::sleep(interval); } }); + *guard = Some(Pinger { stop, thread }); + } + + /// Stop + join the device-level pinger (the LAST slot was just torn down). The join is bounded + /// by the ping interval (watchdog/3 — seconds), same as the old per-monitor pinger join. + fn stop_pinger(&self) { + if let Some(p) = self.pinger.lock().unwrap().take() { + p.stop.store(true, Ordering::Relaxed); + let _ = p.thread.join(); + } + } + + /// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine — + /// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a + /// single member (it sits at the origin), so the single-display path issues no positioning at + /// all. Records each monitor's applied position for the `/display/state` readout. + fn apply_group_layout(&self, inner: &mut MgrInner) { + use crate::vdisplay::layout::{arrange, Member}; + if inner.slots.len() < 2 { + return; + } + let layout_policy = crate::vdisplay::policy::prefs().get().effective().layout; + // Members in acquire (gen) order — the auto-row order; identity slot 0 = anonymous (no + // manual pin can address it, so it always auto-rows). `(slot, gen, target_id, width)` + // copied out so the arrangement below can write back through `get_mut`. + let mut ordered: Vec<(u32, u64, u32, i32)> = inner + .slots + .iter() + .map(|(slot, s)| { + let m = s.mon(); + (*slot, m.gen, m.target_id, m.mode.width as i32) + }) + .collect(); + ordered.sort_by_key(|&(_, gen, _, _)| gen); + let members: Vec = ordered + .iter() + .map(|&(slot, _, _, width)| Member { + identity_slot: (slot != 0).then_some(slot), + width, + }) + .collect(); + let placements = arrange(&members, &layout_policy); + let positions: Vec<(u32, i32, i32)> = ordered + .iter() + .zip(&placements) + .map(|(&(_, _, target, _), p)| (target, p.x, p.y)) + .collect(); + // SAFETY: `apply_source_positions` only drives the CCD query/apply FFI with owned local + // buffers, under the `state` lock — the sole topology mutator. + unsafe { crate::win_display::apply_source_positions(&positions) }; + for (&(slot, ..), p) in ordered.iter().zip(&placements) { + if let Some( + SlotState::Active { mon, .. } + | SlotState::Lingering { mon, .. } + | SlotState::Pinned { mon }, + ) = inner.slots.get_mut(&slot) + { + mon.position = (p.x, p.y); + } + } + } + + /// Create a fresh monitor at `mode` for `slot` (the client's stable identity slot, `0` = auto): + /// ADD via the driver (pinning the discrete render GPU under the usual conditions), ensure the + /// device-level watchdog pinger, resolve the GDI name, force the mode + apply the GROUP topology + /// (first member isolates and captures the restore; a later member re-issues the isolate with + /// the grown managed set — a sibling slot is never deactivated). + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn create_monitor( + &'static self, + dev: HANDLE, + mode: Mode, + slot: u32, + client_hdr: Option, + inner: &mut MgrInner, + ) -> Result { + // The slot id doubles as the driver-preferred monitor id (EDID serial / ConnectorIndex), so + // Windows reapplies the client's saved per-monitor config (DPI scaling) on reconnect; + // `0` (anonymous) = the driver auto-allocates the lowest-free id. + let preferred_id = slot; + let render_pin = resolve_render_pin(); + // SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control + // handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that. + // `render_pin` is an `Option` by value (plain `Copy`), so no borrowed memory + // crosses the call. + let added = unsafe { + self.driver + .add_monitor(dev, mode, render_pin, preferred_id, client_hdr)? + }; + + // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. + // ONE device-level pinger serves every slot (any IOCTL bumps the watchdog); started with the + // first monitor, stopped when the last slot is torn down. + self.ensure_pinger(); // Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its // OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode` @@ -673,51 +843,71 @@ impl VirtualDisplayManager { } } } - let mut ccd_saved: Option = None; - let mut ddc_panels_off = 0u32; - let mut pnp_disabled: Vec = Vec::new(); match &gdi_name { Some(n) => { 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). `Exclusive` (default) deactivates the - // other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary) - // IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the - // physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend` - // leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no - // MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy. + // Apply the display-management topology (Stage 2, GROUP-scoped since Stage W2). + // `Exclusive` (default) deactivates every non-managed display so the IDD set is the + // sole composited desktop — an EXTENDED (non-primary) IDD isn't DWM-composited on + // this box → Desktop Duplication born-losts. The FIRST member captures the restore + // snapshot (+ the experimental DDC/PnP axes); a LATER member only re-issues the + // isolate with the GROWN managed set (design §6.1 — never deactivate a sibling + // slot; the first snapshot is what teardown restores on last-member drop). + // `Primary` keeps the physical display(s) ACTIVE and makes the FIRST member primary + // (the group's designated member); later members just extend + get arranged by the + // group layout. `Extend` leaves it a plain extension. Both isolate + primary go + // through the atomic CCD path (no MODE_CHANGE storm). Opt out (extend) with + // PUNKTFUNK_NO_ISOLATE=1 / the console policy. use crate::vdisplay::policy::Topology; + let first_member = inner.slots.is_empty(); match topology_action() { Topology::Exclusive => { - // EXPERIMENTAL `ddc_power_off` policy axis: command the physical panels - // dark over DDC/CI BEFORE the isolate — an HMONITOR (and with it the DDC - // channel) only exists while the display is still active. A panel that - // believes it has an owner skips its no-signal standby probing — the - // suspected source of the periodic sole-virtual-display stutter (the - // rationale + evidence live in `windows/ddc.rs`). - if crate::vdisplay::policy::prefs().ddc_power_off() { - ddc_panels_off = crate::ddc::panel_off_except(n); - } - // SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the - // `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory - // crosses), under the `state` lock — the sole topology mutator. - ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; - // EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took, - // additionally disable the deactivated monitors' PnP devnodes (persistent - // across hot-plug re-arrival) so a standby monitor/TV's periodic wake - // events no longer trigger the Windows reaction cascade — the suspected - // hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`). - if crate::vdisplay::policy::prefs().pnp_disable_monitors() { - if let Some(saved) = &ccd_saved { - pnp_disabled = crate::monitor_devnode::disable_for_deactivated( - saved, - added.target_id, - ); + // The managed keep-set: every live sibling + the new monitor. + let mut keep = inner.target_ids(); + keep.push(added.target_id); + if first_member { + // EXPERIMENTAL `ddc_power_off` policy axis: command the physical panels + // dark over DDC/CI BEFORE the isolate — an HMONITOR (and with it the DDC + // channel) only exists while the display is still active. A panel that + // believes it has an owner skips its no-signal standby probing — the + // suspected source of the periodic sole-virtual-display stutter (the + // rationale + evidence live in `windows/ddc.rs`). First member only: + // the physicals are already dark for a sibling. + if crate::vdisplay::policy::prefs().ddc_power_off() { + inner.group.ddc_panels_off = crate::ddc::panel_off_except(n); } + // SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it + // takes a borrowed slice of `Copy` target ids (alive across the call) and + // returns an owned `SavedConfig`, under the `state` lock — the sole + // topology mutator. + inner.group.ccd_saved = unsafe { isolate_displays_ccd(&keep) }; + // EXPERIMENTAL `pnp_disable_monitors` policy axis: AFTER the isolate took, + // additionally disable the deactivated monitors' PnP devnodes (persistent + // across hot-plug re-arrival) so a standby monitor/TV's periodic wake + // events no longer trigger the Windows reaction cascade — the suspected + // hiccup mechanism (rationale + crash journal in `windows/monitor_devnode.rs`). + if crate::vdisplay::policy::prefs().pnp_disable_monitors() { + if let Some(saved) = &inner.group.ccd_saved { + inner.group.pnp_disabled = + crate::monitor_devnode::disable_for_deactivated( + saved, + added.target_id, + ); + } + } + } else { + // Grown set: re-isolate so the fresh member joins the composited set + // (its auto-activate may have lit nothing extra to deactivate, but the + // re-commit also drives COMMIT_MODES for the new path). The returned + // snapshot is DISCARDED — the group restores the FIRST member's. + // SAFETY: as above — borrowed slice of Copy ids, owned return, under the + // `state` lock. + let _ = unsafe { isolate_displays_ccd(&keep) }; } } - Topology::Primary => { + Topology::Primary if first_member => { // On a headless box the IDD auto-activates as the SOLE display, so a physical // (if present) is deactivated and QueryDisplayConfig sees only the virtual — // force EXTEND to (re)activate every connected display alongside the virtual, @@ -728,10 +918,10 @@ impl VirtualDisplayManager { // persistence DB, RESETTING a 120 Hz panel to 60 Hz. So force-EXTEND only when the // virtual is currently sole; otherwise skip straight to the reposition, which // re-supplies each physical's QUERIED mode verbatim (preserving its refresh). - // SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI (Copy target id - // by value, owned result), under the `state` lock. + // SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI (borrowed + // slice of Copy ids, owned result), under the `state` lock. let already_extended = - unsafe { count_other_active(added.target_id) }.unwrap_or(0) > 0; + unsafe { count_other_active(&[added.target_id]) }.unwrap_or(0) > 0; if already_extended { tracing::info!( "display topology=primary — a physical display is already active; \ @@ -746,7 +936,15 @@ 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. - 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 + // monitor just extends; the group layout arranges it. + tracing::info!( + "display topology=primary — sibling slot holds primary; new member extends" + ); } Topology::Extend | Topology::Auto => { tracing::info!( @@ -770,11 +968,8 @@ impl VirtualDisplayManager { wudf_pid: added.wudf_pid, gdi_name, mode, - stop, - pinger: Some(pinger), - ccd_saved, - ddc_panels_off, - pnp_disabled, + resolved_monitor_id: added.resolved_monitor_id, + position: (0, 0), gen: self.gen.fetch_add(1, Ordering::Relaxed), }) } @@ -803,11 +998,13 @@ impl VirtualDisplayManager { mon.mode = mode; } - /// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor. Consumes it. + /// Tear down `mon`, which the caller has ALREADY removed from `inner.slots`: on the LAST member + /// stop the device pinger + restore the group topology; on a non-last member re-issue the + /// exclusive isolate with the SHRUNK managed set; then REMOVE the monitor. Consumes it. /// /// # Safety /// `dev` must be the live control handle. - unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) { + unsafe fn teardown_removed(&self, dev: HANDLE, inner: &mut MgrInner, mon: Monitor) { // Wedge visibility: this runs synchronously — usually UNDER the `state` lock (linger timer, // reconnect preempt, quit-skip), so a REMOVE/CCD-restore that never returns (field signature: // Windows AMD reconnects going silently dead) blocks every future `acquire` with NOTHING in the @@ -830,38 +1027,54 @@ impl VirtualDisplayManager { }) .ok(); } - mon.stop.store(true, Ordering::Relaxed); - if let Some(j) = mon.pinger.take() { - let _ = j.join(); - } - // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays. - if let Some(saved) = &mon.ccd_saved { - // EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let - // them re-arrive, so the CCD restore below re-activates paths whose monitors exist - // again (a disabled devnode would leave the restored path modeless/EDID-less). - if !mon.pnp_disabled.is_empty() { - crate::monitor_devnode::enable_instances(&mon.pnp_disabled); - thread::sleep(Duration::from_millis(300)); - } - restore_displays_ccd(saved); - // EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and - // returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is - // belt-and-braces for the rest. The brief settle wait lets the re-activated paths show - // up in EnumDisplayMonitors (no HMONITOR, no DDC channel); teardown is already - // seconds-scale and watched by the 10 s wedge logger above. - if mon.ddc_panels_off > 0 { - thread::sleep(Duration::from_millis(300)); - let woken = crate::ddc::panel_on_all(); - tracing::info!( - commanded_off = mon.ddc_panels_off, - woken, - "DDC/CI: panel wake commands sent after topology restore" - ); + let last_member = inner.slots.is_empty(); + if last_member { + // The LAST slot is going away: the device-level pinger has nothing left to keep alive + // (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs + // — first-in captured it, last-out restores it (design §6.1). + self.stop_pinger(); + // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero + // displays. + if let Some(saved) = inner.group.ccd_saved.take() { + // EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let + // them re-arrive, so the CCD restore below re-activates paths whose monitors exist + // again (a disabled devnode would leave the restored path modeless/EDID-less). + let pnp_disabled = std::mem::take(&mut inner.group.pnp_disabled); + if !pnp_disabled.is_empty() { + crate::monitor_devnode::enable_instances(&pnp_disabled); + thread::sleep(Duration::from_millis(300)); + } + restore_displays_ccd(&saved); + // EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and + // returning signal alone wakes DPMS-off panels on most firmware — the explicit ON is + // belt-and-braces for the rest. The brief settle wait lets the re-activated paths show + // up in EnumDisplayMonitors (no HMONITOR, no DDC channel); teardown is already + // seconds-scale and watched by the 10 s wedge logger above. + if inner.group.ddc_panels_off > 0 { + thread::sleep(Duration::from_millis(300)); + let woken = crate::ddc::panel_on_all(); + tracing::info!( + commanded_off = inner.group.ddc_panels_off, + woken, + "DDC/CI: panel wake commands sent after topology restore" + ); + inner.group.ddc_panels_off = 0; + } } + } else if inner.group.ccd_saved.is_some() { + // Siblings remain and an exclusive isolate is live: re-issue it with the SHRUNK managed + // set (defensive — the departing monitor's path dies with the REMOVE below anyway, but + // a re-commit keeps the surviving set authoritative if the OS re-lit anything). The + // returned snapshot is discarded; the group keeps the first member's. + let keep = inner.target_ids(); + // SAFETY: `isolate_displays_ccd` only drives the CCD query/apply FFI over a borrowed + // slice of Copy target ids, under the `state` lock — the sole topology mutator. + let _ = unsafe { isolate_displays_ccd(&keep) }; } - // SAFETY: `teardown`'s own `# Safety` contract guarantees `dev` is the live control handle, and - // `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` inside the - // still-owned `mon`, alive for this synchronous IOCTL, so the pointer the driver reads stays valid. + // SAFETY: `teardown_removed`'s own `# Safety` contract guarantees `dev` is the live control + // handle, and `remove_monitor` requires exactly that. `&mon.key` borrows the `MonitorKey` + // inside the still-owned `mon`, alive for this synchronous IOCTL, so the pointer the driver + // reads stays valid. if let Err(e) = unsafe { self.driver.remove_monitor(dev, &mon.key) } { // A gone-classified failure means the device died under this monitor (driver upgrade / // WUDFHost restart) — retire the handle so the NEXT session reopens instead of failing. @@ -888,119 +1101,140 @@ impl VirtualDisplayManager { /// deliberate quit still pins — only `/display/release` frees a pinned monitor. A STALE lease /// (its monitor was preempted + recreated under it) is a no-op, so it can't tear down the /// CURRENT monitor. - fn release(&self, gen: u64, quit_now: bool) { - let mut state = self.state.lock().unwrap(); - let stale = match &*state { - MgrState::Active { mon, .. } - | MgrState::Lingering { mon, .. } - | MgrState::Pinned { mon } => mon.gen != gen, - MgrState::Idle => true, + fn release(&self, slot: u32, gen: u64, quit_now: bool) { + let mut inner = self.state.lock().unwrap(); + let stale = match inner.slots.get(&slot) { + Some(s) => s.mon().gen != gen, + None => true, }; if stale { return; } - *state = match std::mem::replace(&mut *state, MgrState::Idle) { - MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { - mon, - refs: refs - 1, - }, - // Last session left: keep the monitor forever (Pinned) under `keep_alive = forever` — - // checked BEFORE the quit, because the gaming-rig preset's contract is "the screen - // stays alive": a deliberate quit skips only the linger window, never the pin. - MgrState::Active { mon, .. } if keep_alive_forever() => { + let Some(entry) = inner.slots.remove(&slot) else { + return; + }; + match entry { + SlotState::Active { mon, refs } if refs > 1 => { + inner.slots.insert( + slot, + SlotState::Active { + mon, + refs: refs - 1, + }, + ); + } + // Last session left this slot: keep the monitor forever (Pinned) under + // `keep_alive = forever` — checked BEFORE the quit, because the gaming-rig preset's + // contract is "the screen stays alive": a deliberate quit skips only the linger window, + // never the pin. + SlotState::Active { mon, .. } if keep_alive_forever() => { tracing::info!( + slot, "virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release" ); - MgrState::Pinned { mon } + inner.slots.insert(slot, SlotState::Pinned { mon }); } // Last session left on a deliberate quit: tear down NOW (linger skipped). Teardown // runs UNDER the state lock — same shape as the linger timer, and for the same reason: a - // racing `acquire` must WAIT the teardown out rather than see Idle and ADD into the - // driver's in-flight REMOVE. `device_handle()` is only None if the control device was - // never opened — impossible with a monitor live — but fall back to Lingering (the timer - // retries) rather than leak the monitor. - MgrState::Active { mon, .. } if quit_now => match self.device_handle() { + // racing `acquire` must WAIT the teardown out rather than see an empty slot and ADD into + // the driver's in-flight REMOVE. `device_handle()` is only None if the control device + // was never opened — impossible with a monitor live — but fall back to Lingering (the + // timer retries) rather than leak the monitor. + SlotState::Active { mon, .. } if quit_now => match self.device_handle() { Some(dev) => { tracing::info!( + slot, "virtual-display: last session left (deliberate quit) — tearing down now, linger skipped" ); - // SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the - // cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` checked - // above; cached handles are never closed — a dead one is retired, kept alive). `mon` - // was moved out of the `Active` state under the `state` lock, so it is exclusively - // owned here — no aliasing. - unsafe { self.teardown(dev, mon) }; - MgrState::Idle + // SAFETY: `teardown_removed` requires `dev` to be the live control handle; `dev` + // is the cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` + // checked above; cached handles are never closed — a dead one is retired, kept + // alive). `mon` was moved out of the map under the `state` lock, so it is + // exclusively owned here — no aliasing. + unsafe { self.teardown_removed(dev, &mut inner, mon) }; + } + None => { + inner.slots.insert( + slot, + SlotState::Lingering { + mon, + until: Instant::now() + Duration::from_millis(linger_ms()), + }, + ); } - None => MgrState::Lingering { - mon, - until: Instant::now() + Duration::from_millis(linger_ms()), - }, }, // Last session left, no quit signal: linger for the policy window before the timer // tears it down. - MgrState::Active { mon, .. } => { + SlotState::Active { mon, .. } => { let ms = linger_ms(); tracing::info!( + slot, linger_ms = ms, "virtual-display: last session left — lingering before teardown" ); - MgrState::Lingering { - mon, - until: Instant::now() + Duration::from_millis(ms), - } + inner.slots.insert( + slot, + SlotState::Lingering { + mon, + until: Instant::now() + Duration::from_millis(ms), + }, + ); } - other => other, - }; + // A kept (Lingering/Pinned) slot has no live hold — a release reaching it is + // stale/duplicate; put it back untouched. + other => { + inner.slots.insert(slot, other); + } + } } /// Begin an IDD-push session setup (Goal-1 §2.5 — was the `IDD_SETUP_LOCK` / `IDD_SESSION_STOP` / - /// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the setup lock, - /// registers THIS session's stop flag while signalling the PRIOR IDD-push session to stop, and waits - /// for it to release its monitor — so a reconnect (whose reused IddCx swap-chain is dead) preempts the - /// stale session cleanly before a fresh monitor is created. Returns the setup guard; the caller holds - /// it across the pipeline build, then drops it so the next reconnect can begin (and preempt this one). + /// `wait_for_monitor_released` dance smeared across `punktfunk1`). Serializes via the (manager-wide) + /// setup lock, registers THIS session's stop flag on its SLOT while signalling the prior session + /// holding that slot to stop, and waits for it to release the slot's monitor — so a reconnect + /// (whose reused IddCx swap-chain is dead) preempts the stale session cleanly before a fresh + /// monitor is created. Slot-scoped since Stage W1: a DIFFERENT identity's session is an admission + /// question, never a preempt. Returns the setup guard; the caller holds it across the pipeline + /// build, then drops it so the next reconnect can begin (and preempt this one). pub(crate) fn begin_idd_setup( &'static self, + slot: u32, stop: Arc, ) -> std::sync::MutexGuard<'static, ()> { let guard = self.setup_lock.lock().unwrap(); - let prev = self.idd_session_stop.lock().unwrap().replace(stop); + let prev = self.idd_session_stops.lock().unwrap().insert(slot, stop); if let Some(prev_stop) = prev { prev_stop.store(true, Ordering::SeqCst); - if !self.wait_for_monitor_released(Duration::from_secs(3)) { - // TIMEOUT: the prior session is STILL Active (a wedged/slow teardown). `acquire`'s preempt - // is now Lingering-only (so build-retries JOIN the held monitor instead of churning - // REMOVE→ADD), which means the upcoming `_retry_hold` acquire would JOIN this stuck monitor - // and reuse its DEAD IddCx swap-chain → a full-session black screen with no self-heal until - // this session disconnects. Force-preempt it HERE instead. This runs at most ONCE per - // session (we hold `setup_lock`), so — unlike preempting inside `acquire` — it does not - // reintroduce the per-retry churn. The next `acquire` then sees `Idle` and creates a fresh - // monitor; the stale session's gen-stamped lease release is a no-op. + if !self.wait_for_slot_released(slot, Duration::from_secs(3)) { + // TIMEOUT: the prior session is STILL Active on this slot (a wedged/slow teardown). + // `acquire`'s preempt is Lingering-only (so build-retries JOIN the held monitor + // instead of churning REMOVE→ADD), which means the upcoming `_retry_hold` acquire + // would JOIN this stuck monitor and reuse its DEAD IddCx swap-chain → a full-session + // black screen with no self-heal until this session disconnects. Force-preempt it + // HERE instead. This runs at most ONCE per session (we hold `setup_lock`), so — + // unlike preempting inside `acquire` — it does not reintroduce the per-retry churn. + // The next `acquire` then sees the slot empty and creates a fresh monitor; the stale + // session's gen-stamped lease release is a no-op. if let Some(dev) = self.device_handle() { - let taken = { - let mut state = self.state.lock().unwrap(); - match std::mem::replace(&mut *state, MgrState::Idle) { - MgrState::Active { mon, .. } => Some(mon), - // Raced to Lingering/Idle between the wait and here — restore + nothing stuck. - other => { - *state = other; - None - } - } + let mut inner = self.state.lock().unwrap(); + let taken = match inner.slots.get(&slot) { + Some(SlotState::Active { .. }) => inner.slots.remove(&slot), + // Raced to Lingering/empty between the wait and here — nothing stuck. + _ => None, }; - if let Some(mon) = taken { + if let Some(SlotState::Active { mon, .. }) = taken { tracing::warn!( + slot, old_target = mon.target_id, "IDD-push setup: force-preempting the stuck-Active prior monitor (its IddCx swap-chain is dead)" ); - // SAFETY: `teardown` requires `dev` to be the live control handle; `dev` is the - // cached process-lifetime `OwnedHandle` from `device_handle()` (the `Some` checked - // above). `mon` was moved out of the `Active` state under the `state` lock, so it is - // exclusively owned here — no aliasing. - unsafe { self.teardown(dev, mon) }; - // Let the OS finish the ASYNC departure before the next ADD (mirrors the acquire() - // Lingering-preempt settle). + // SAFETY: `teardown_removed` requires `dev` to be the live control handle; + // `dev` is the cached process-lifetime `OwnedHandle` from `device_handle()` + // (the `Some` checked above). `mon` was moved out of the map under the + // `state` lock, so it is exclusively owned here — no aliasing. + unsafe { self.teardown_removed(dev, &mut inner, mon) }; + // Let the OS finish the ASYNC departure before the next ADD (mirrors the + // acquire() Lingering-preempt settle). thread::sleep(Duration::from_millis(400)); } } @@ -1009,18 +1243,22 @@ impl VirtualDisplayManager { guard } - /// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`). - /// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it - /// tears its monitor down cleanly before we acquire a fresh one. Returns `true` if it released, `false` - /// on timeout (the prior session is still `Active` — the caller force-preempts it). - pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) -> bool { + /// Wait (up to `timeout`) for `slot` to be RELEASED (no longer `Active`). Used by the IDD-push + /// reconnect preempt: after signalling the old session to stop, wait here so it tears its monitor + /// down cleanly before we acquire a fresh one. Returns `true` if it released, `false` on timeout + /// (the prior session is still `Active` — the caller force-preempts it). + pub(crate) fn wait_for_slot_released(&self, slot: u32, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { - if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) { + if !matches!( + self.state.lock().unwrap().slots.get(&slot), + Some(SlotState::Active { .. }) + ) { return true; } if Instant::now() >= deadline { tracing::warn!( + slot, "IDD-push preempt: prior session didn't release the monitor within {timeout:?} — force-preempting" ); return false; @@ -1042,26 +1280,32 @@ impl VirtualDisplayManager { continue; }; let mut g = self.state.lock().unwrap(); - if !matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) - { - continue; - } - if let MgrState::Lingering { mon, .. } = - std::mem::replace(&mut *g, MgrState::Idle) - { - // Teardown UNDER the state lock. Dropping the lock first (the old shape) let a - // concurrent `acquire` see Idle and run its ADD + CCD isolate while this - // monitor's pinger-join / CCD-restore / REMOVE were still in flight — the late - // restore then de-isolated (or the REMOVE churn-rejected) the fresh session at - // the linger-expiry boundary. Holding the lock makes the racing acquire WAIT - // the few teardown seconds instead of failing its session. Lock order stays - // state → device (teardown's invalidate path), same as every other holder; the - // pinger takes only the device lock — no inversion. - // SAFETY: `teardown` requires a valid control handle; `dev` is from - // `self.device_handle()` (cached handles are never closed — a dead one is - // retired, kept alive; see `DeviceSlot`). `mon` was moved out of the replaced - // state under the lock, so it is exclusively owned here. - unsafe { self.teardown(dev, mon) }; + let now = Instant::now(); + let expired: Vec = g + .slots + .iter() + .filter_map(|(slot, s)| { + matches!(s, SlotState::Lingering { until, .. } if now >= *until) + .then_some(*slot) + }) + .collect(); + for slot in expired { + if let Some(SlotState::Lingering { mon, .. }) = g.slots.remove(&slot) { + // Teardown UNDER the state lock. Dropping the lock first (the old shape) + // let a concurrent `acquire` see the slot empty and run its ADD + CCD + // isolate while this monitor's CCD-restore / REMOVE were still in flight + // — the late restore then de-isolated (or the REMOVE churn-rejected) the + // fresh session at the linger-expiry boundary. Holding the lock makes + // the racing acquire WAIT the few teardown seconds instead of failing + // its session. Lock order stays state → device (teardown's invalidate + // path), same as every other holder; the pinger takes only the device + // lock — no inversion. + // SAFETY: `teardown_removed` requires a valid control handle; `dev` is + // from `self.device_handle()` (cached handles are never closed — a dead + // one is retired, kept alive; see `DeviceSlot`). `mon` was moved out of + // the map under the lock, so it is exclusively owned here. + unsafe { self.teardown_removed(dev, &mut g, mon) }; + } } }) .ok(); @@ -1069,10 +1313,11 @@ impl VirtualDisplayManager { } } -/// The session's refcount handle. `Drop` releases the manager's refcount; a stale lease (its monitor was -/// preempted + recreated under it) is a no-op. +/// The session's refcount handle on its SLOT. `Drop` releases the slot's refcount; a stale lease (its +/// monitor was preempted + recreated under it) is a no-op. struct MonitorLease { mgr: &'static VirtualDisplayManager, + slot: u32, gen: u64, /// The session's deliberate-quit flag (the client closed with the QUIT application code — a user /// "stop", not a network drop). Read at drop time: a quit release tears the monitor down NOW @@ -1084,10 +1329,25 @@ struct MonitorLease { impl Drop for MonitorLease { fn drop(&mut self) { let quit_now = self.quit.as_ref().is_some_and(|q| q.load(Ordering::SeqCst)); - self.mgr.release(self.gen, quit_now); + self.mgr.release(self.slot, self.gen, quit_now); } } +/// The SLOT id keying a client's monitor in the manager: the stable per-client identity slot +/// (`1..=15`, `identity::resolve_slot` — Windows defaults to PerClient, its historical behavior), or +/// `0` for anonymous/GameStream sessions (the driver then auto-allocates the monitor id; at most one +/// anonymous slot at a time — exactly the pre-slot-map semantics, since an anonymous re-acquire has +/// no identity to find any other slot by). Shared by `acquire` and the session setup's +/// [`VirtualDisplayManager::begin_idd_setup`], so both address the same slot. +pub(crate) fn slot_id_for(client_fp: Option<[u8; 32]>, mode: (u32, u32)) -> u32 { + super::identity::resolve_slot( + client_fp, + mode, + crate::vdisplay::policy::Identity::PerClient, + ) + .unwrap_or(0) +} + /// The render-GPU pin (backend-neutral): IDD-push — the sole Windows capture path — runs NVENC on the /// render adapter, so it must always be pinned to the selected encoder GPU (a hybrid box would /// otherwise render on the wrong one). The selection itself (web-console preference > @@ -1129,7 +1389,7 @@ fn warn_if_pick_moved(mon: &Monitor) { } } -/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal: +/// A read-only view of one managed slot for the mgmt `/display/state` endpoint (Goal: /// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade /// maps it into the wire shape. pub(crate) struct ManagedInfo { @@ -1143,75 +1403,99 @@ pub(crate) struct ManagedInfo { pub sessions: u32, /// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg. pub gen: u64, + /// The slot key: the client's stable identity slot (`1..=15`), or `0` = anonymous/auto. + pub slot_id: u32, + /// Desktop-space origin from the group layout (`(0,0)` for a single display). + pub position: (i32, i32), } impl VirtualDisplayManager { - /// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle. - pub(crate) fn snapshot(&self) -> Option { - let st = self.state.lock().unwrap(); - let (mon, state, sessions, expires_in_ms) = match &*st { - MgrState::Idle => return None, - MgrState::Active { mon, refs } => (mon, "active", *refs, None), - MgrState::Lingering { mon, until } => { - let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64; - (mon, "lingering", 0u32, Some(ms)) - } - // Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows "Pinned". - MgrState::Pinned { mon } => (mon, "pinned", 0u32, None), - }; - Some(ManagedInfo { - backend: self.driver.name(), - mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz), - state, - expires_in_ms, - sessions, - gen: mon.gen, - }) + /// Snapshot the managed slots for the mgmt `/display/state` endpoint, in acquire (gen) order. + /// Empty when no slot lives. + pub(crate) fn snapshot(&self) -> Vec { + let inner = self.state.lock().unwrap(); + let mut out: Vec = inner + .slots + .iter() + .map(|(slot, s)| { + let (mon, state, sessions, expires_in_ms) = match s { + SlotState::Active { mon, refs } => (mon, "active", *refs, None), + SlotState::Lingering { mon, until } => { + let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64; + (mon, "lingering", 0u32, Some(ms)) + } + // Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows + // "Pinned". + SlotState::Pinned { mon } => (mon, "pinned", 0u32, None), + }; + ManagedInfo { + backend: self.driver.name(), + mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz), + state, + expires_in_ms, + sessions, + gen: mon.gen, + slot_id: *slot, + position: mon.position, + } + }) + .collect(); + out.sort_by_key(|i| i.gen); + out } - /// Force-tear-down a kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) — + /// Force-tear-down kept (LINGERING **or** PINNED) monitors now (the `/display/release` endpoint) — /// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8 - /// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused - /// (stopping a live session is session management, not display management). Returns `true` if a kept - /// monitor was released. - pub(crate) fn force_release(&self) -> bool { + /// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. `slot` selects one kept monitor + /// by its [`ManagedInfo::gen`] stamp; `None` releases every kept one. Active monitors are refused + /// (stopping a live session is session management, not display management). Returns the number + /// released. + pub(crate) fn force_release(&self, slot: Option) -> usize { let Some(dev) = self.device_handle() else { - return false; + return 0; }; - let mut st = self.state.lock().unwrap(); - if matches!(&*st, MgrState::Lingering { .. } | MgrState::Pinned { .. }) { - let mon = match std::mem::replace(&mut *st, MgrState::Idle) { - MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon), - other => { - *st = other; - None + let mut inner = self.state.lock().unwrap(); + let kept: Vec = inner + .slots + .iter() + .filter_map(|(k, s)| match s { + SlotState::Lingering { mon, .. } | SlotState::Pinned { mon } + if slot.is_none_or(|g| g == mon.gen) => + { + Some(*k) } - }; - if let Some(mon) = mon { - // SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()` - // (cached handles are never closed — a dead one is retired, kept alive; see - // `DeviceSlot`). `mon` was moved out of the kept state under the `state` lock, + _ => None, + }) + .collect(); + let mut released = 0usize; + for k in kept { + if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) = + inner.slots.remove(&k) + { + // SAFETY: `teardown_removed` needs a live control handle; `dev` is from + // `device_handle()` (cached handles are never closed — a dead one is retired, kept + // alive; see `DeviceSlot`). `mon` was moved out of the map under the `state` lock, // so it is exclusively owned here — no aliasing. - unsafe { self.teardown(dev, mon) }; - return true; + unsafe { self.teardown_removed(dev, &mut inner, mon) }; + released += 1; } } - false + released } } -/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no -/// session has ever run) or it is Idle. Safe to call per management request. -pub(crate) fn snapshot() -> Option { - VDM.get().and_then(VirtualDisplayManager::snapshot) +/// Snapshot the managed slots (empty when no backend has initialised the manager yet — no session +/// has ever run — or none lives). Safe to call per management request. +pub(crate) fn snapshot() -> Vec { + VDM.get() + .map(VirtualDisplayManager::snapshot) + .unwrap_or_default() } -/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is -/// uninitialised). -pub(crate) fn force_release() -> bool { - VDM.get() - .map(VirtualDisplayManager::force_release) - .unwrap_or(false) +/// Force-release kept monitors now (`slot` = a [`ManagedInfo::gen`] stamp, `None` = all kept); `0` +/// if nothing was kept (or the manager is uninitialised). +pub(crate) fn force_release(slot: Option) -> usize { + VDM.get().map(|m| m.force_release(slot)).unwrap_or(0) } /// Linger window before a session-less monitor is torn down. The console display-management policy diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 7bde49b6..5a1f61fd 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -432,14 +432,33 @@ impl VdisplayDriver for PfVdisplayDriver { mode: Mode, render_luid: Option, preferred_monitor_id: u32, + client_hdr: Option, ) -> Result { let session_id = next_session_id(); + // The client display's volume rides into the monitor's EDID CTA HDR block; all-zero = + // unknown → the driver keeps its built-in defaults (also what an un-upgraded driver, which + // reads only the legacy 24-byte prefix, does). + let (max_luminance_nits, max_frame_avg_nits, min_luminance_millinits) = client_hdr + .map(|m| crate::hdr::vdisplay_luminance_fields(&m)) + .unwrap_or((0, 0, 0)); + if max_luminance_nits > 0 { + tracing::info!( + max_luminance_nits, + max_frame_avg_nits, + min_luminance_millinits, + "pf-vdisplay ADD: advertising the client display's HDR volume in the monitor EDID" + ); + } let add = control::AddRequest { session_id, width: mode.width, height: mode.height, refresh_hz: mode.refresh_hz, preferred_monitor_id, + max_luminance_nits, + max_frame_avg_nits, + min_luminance_millinits, + _reserved: 0, }; // SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports // its real render LUID in the shared header, so the host binds correctly even if this is ignored. @@ -550,6 +569,7 @@ impl VdisplayDriver for PfVdisplayDriver { target_id: reply.target_id, luid, wudf_pid: reply.wudf_pid, + resolved_monitor_id: reply.resolved_monitor_id, }) } @@ -590,6 +610,11 @@ pub struct PfVdisplayDisplay { /// The connecting client's cert fingerprint (`None` = anonymous/GameStream → the manager's auto id). /// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`. client_fp: Option<[u8; 32]>, + /// The client display's HDR colour volume (`None` = unknown/SDR → the driver's built-in EDID + /// defaults). Set by [`set_client_hdr`](VirtualDisplay::set_client_hdr) before `create`; a + /// freshly created monitor's EDID advertises this volume so host apps tone-map to the client's + /// real panel. + client_hdr: Option, /// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by /// [`set_quit_flag`](VirtualDisplay::set_quit_flag) before `create`; rides into every lease this /// backend mints so a user "stop" tears the monitor down immediately instead of lingering. @@ -601,6 +626,7 @@ impl PfVdisplayDisplay { super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?; Ok(Self { client_fp: None, + client_hdr: None, quit: None, }) } @@ -615,12 +641,16 @@ impl VirtualDisplay for PfVdisplayDisplay { self.client_fp = fingerprint; } + fn set_client_hdr(&mut self, hdr: Option) { + self.client_hdr = hdr; + } + fn set_quit_flag(&mut self, quit: std::sync::Arc) { self.quit = Some(quit); } fn create(&mut self, mode: Mode) -> Result { - super::manager::vdm().acquire(mode, self.client_fp, self.quit.clone()) + super::manager::vdm().acquire(mode, self.client_fp, self.client_hdr, self.quit.clone()) } } diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index c748e82c..b2a07ce8 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -386,17 +386,18 @@ unsafe fn query_active_config() -> Option { Some((paths, modes)) } -/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would -/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually -/// took, and (in the `primary` topology) to detect a physical that is ALREADY active so we can skip a -/// force-EXTEND that would reset its refresh. -pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option { +/// Count currently-ACTIVE display paths whose target id is not in `keep_target_ids` — i.e. displays +/// that would still be lit besides the managed virtual set. `None` on query failure. Used to VERIFY +/// isolation actually took, and (in the `primary` topology) to detect a physical that is ALREADY +/// active so we can skip a force-EXTEND that would reset its refresh. +pub(crate) unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option { let (paths, _) = query_active_config()?; Some( paths .iter() .filter(|p| { - p.targetInfo.id != keep_target_id && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 + !keep_target_ids.contains(&p.targetInfo.id) + && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 }) .count() as u32, ) @@ -406,24 +407,28 @@ pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option { /// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't /// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop / /// lock screen lands on IT while our virtual output freezes. `QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS)` -/// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual -/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same -/// way (CCD). Returns the original active config to restore on teardown. +/// sees every active path; we deactivate all of them EXCEPT the managed virtual target **set** +/// (`design/display-management.md` §6.1: "exclusive" means the managed set stays active — with +/// parallel displays a sibling slot is never deactivated), leaving the virtual display(s) as the sole +/// desktop so ALL content (incl. Winlogon) renders to them. Apollo isolates the same way (CCD). +/// Re-issued with the grown/shrunk set on each slot add/remove while the group lives; the FIRST call's +/// returned config is what teardown restores (the caller keeps it on the group record and discards +/// later returns). Returns the original active config to restore on teardown. // pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper -// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies). -pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option { +// (it operates on real OS target ids — a pf-vdisplay monitor's target_id qualifies). +pub(crate) unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option { // Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes. let saved = query_active_config()?; // Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical // monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the - // live topology each attempt and re-apply until ONLY the keep target is active. Secure-desktop + // live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop // correctness depends on this — the lock screen must not land on a stray panel while we stream. for attempt in 1..=4u32 { let (mut paths, modes) = query_active_config()?; let mut others = 0u32; for p in paths.iter_mut() { - if p.targetInfo.id == keep_target_id { + if keep_target_ids.contains(&p.targetInfo.id) { continue; } if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 { @@ -446,19 +451,104 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option Option<(i32, i32, i32, i32)> { + let (paths, modes) = query_active_config()?; + for p in &paths { + if p.targetInfo.id != target_id || p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 { + continue; + } + let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize; + let m = modes.get(idx)?; + if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE { + return None; + } + let sm = m.Anonymous.sourceMode; + return Some(( + sm.position.x, + sm.position.y, + sm.width as i32, + sm.height as i32, + )); + } + None +} + +/// Place each managed virtual target's SOURCE at the given desktop-space origin, as ONE atomic CCD +/// `SetDisplayConfig` (design `display-management.md` §6.2 — the Windows arm of the pure +/// `vdisplay/layout.rs` arrangement; positions come from `arrange`, this only commits them). Windows +/// treats the source at `(0,0)` as primary, so auto-row's first member lands primary — the group's +/// designated member. Paths not named stay where they are. Best-effort: a failure leaves the OS +/// placement (mouse crossing may not match the layout table until the next apply). +pub(crate) unsafe fn apply_source_positions(positions: &[(u32, i32, i32)]) { + if positions.len() < 2 { + return; // a single (or no) member sits at the origin — nothing to arrange + } + let Some((paths, mut modes)) = query_active_config() else { + return; + }; + // Dedup source-mode indices (a cloned group shares one) — same discipline as + // `set_virtual_primary_ccd`. + let mut done = std::collections::HashSet::new(); + let mut moved = 0u32; + for p in paths.iter() { + let Some(&(_, x, y)) = positions.iter().find(|(t, _, _)| *t == p.targetInfo.id) else { + continue; + }; + let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize; + if !done.insert(idx) { + continue; + } + let Some(m) = modes.get_mut(idx) else { + continue; + }; + if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE { + continue; + } + m.Anonymous.sourceMode.position = POINTL { x, y }; + moved += 1; + } + if moved == 0 { + return; + } + let rc = SetDisplayConfig( + Some(paths.as_slice()), + Some(modes.as_slice()), + SDC_APPLY + | SDC_USE_SUPPLIED_DISPLAY_CONFIG + | SDC_ALLOW_CHANGES + | SDC_FORCE_MODE_ENUMERATION, + ); + if rc == 0 { + tracing::info!( + ?positions, + "display layout (CCD): group source origins applied" + ); + } else { + tracing::warn!( + ?positions, + "display layout (CCD): SetDisplayConfig rc={rc:#x}" + ); + } +} + /// **Primary (topology=primary)** — make the virtual output the PRIMARY display while KEEPING every /// other display ACTIVE (unlike [`isolate_displays_ccd`], which deactivates them). Windows treats the /// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 5d31dd21..366f93cd 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -295,6 +295,13 @@ #define HDR_META_MAGIC 206 #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32 +// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which +// prefixes the tag) and the `Hello::display_hdr` trailing field (which carries the bare body). +#define HDR_META_BODY_LEN (((12 + 4) + 8) + 4) +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after // [`HDR_META_MAGIC`]. Emitted once per access unit, right after its last packet left the host's @@ -314,11 +321,13 @@ #if defined(PUNKTFUNK_FEATURE_QUIC) // [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC -// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`). The host emits 4:4:4 ONLY when this -// bit is set, the host opted in (`PUNKTFUNK_444`), the codec is HEVC, **and** the GPU/driver -// actually supports a 4:4:4 encode (probed) — otherwise the session stays 4:2:0 and -// [`Welcome::chroma_format`] reflects the real resolved value. Independent of 10-bit/HDR (4:4:4 is a -// chroma decision, bit depth is a depth decision; the two may combine where the hardware allows). +// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a +// client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY +// when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC, +// **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays +// 4:2:0 and [`Welcome::chroma_format`] reflects the real resolved value. Independent of +// 10-bit/HDR (4:4:4 is a chroma decision, bit depth is a depth decision; the two may combine +// where the hardware allows). #define VIDEO_CAP_444 4 #endif diff --git a/packaging/flatpak/io.unom.Punktfunk.yml b/packaging/flatpak/io.unom.Punktfunk.yml index 46d8a5bf..f16188d0 100644 --- a/packaging/flatpak/io.unom.Punktfunk.yml +++ b/packaging/flatpak/io.unom.Punktfunk.yml @@ -84,6 +84,22 @@ finish-args: - --share=network # --- persistent client identity / pairing store (shared with punktfunk-probe) --- - --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings + # --- HDR under gamescope (Steam Deck Game Mode) --- + # A flatpak's Vulkan loader can't see the host's gamescope WSI layer, so the SDL3 surface never + # offers the HDR10 (ST.2084) colorspace and the presenter silently tone-maps PQ->SDR — the + # Game-Mode HDR indicator stays dark (verified on a Deck OLED: the sandbox loader found NO + # frog/gamescope layer). The layer ships as the runtime extension + # `org.freedesktop.Platform.VulkanLayer.gamescope`, which org.gnome.Platform//50 auto-mounts at + # /usr/lib/extensions/vulkan/gamescope once installed (a one-time, per-Deck step; keep it in the + # Decky plugin's setup / docs): + # flatpak install --user -y flathub org.freedesktop.Platform.VulkanLayer.gamescope//25.08 + # These two env vars are all the app itself needs: the first puts the layer's implicit-layer JSON + # on the Vulkan loader's search path (the runtime point mounts the files but not onto the path), + # the second flips the layer's own `enable_environment` gate. With both, the surface starts + # offering HDR10 and the presenter's existing HDR10 swapchain path engages automatically — no + # client code change. Harmless off-Deck: the layer no-ops when there's no gamescope socket. + - --env=VK_ADD_IMPLICIT_LAYER_PATH=/usr/lib/extensions/vulkan/gamescope/share/vulkan/implicit_layer.d + - --env=ENABLE_GAMESCOPE_WSI=1 build-options: append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin diff --git a/packaging/windows/drivers/pf-vdisplay/src/control.rs b/packaging/windows/drivers/pf-vdisplay/src/control.rs index c109786c..414c5a97 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/control.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/control.rs @@ -127,7 +127,7 @@ unsafe fn set_render_adapter(request: WDFREQUEST) { /// `request` is the framework `WDFREQUEST`. unsafe fn add(request: WDFREQUEST) { // SAFETY: `request` is the framework WDFREQUEST. - let Some(req) = (unsafe { read_input::(request) }) else { + let Some(req) = (unsafe { read_add_request(request) }) else { complete(request, STATUS_INVALID_PARAMETER); return; }; @@ -141,6 +141,11 @@ unsafe fn add(request: WDFREQUEST) { req.height, req.refresh_hz, req.preferred_monitor_id, + crate::edid::ClientLuminance { + max_nits: req.max_luminance_nits, + max_frame_avg_nits: req.max_frame_avg_nits, + min_millinits: req.min_luminance_millinits, + }, ) else { complete(request, STATUS_NOT_FOUND); return; @@ -207,6 +212,41 @@ unsafe fn remove(request: WDFREQUEST) { complete(request, STATUS_SUCCESS); } +/// Read an [`control::AddRequest`], accepting BOTH wire sizes: the full struct, or an un-upgraded +/// host's [`ADD_REQUEST_LEGACY_SIZE`](control::ADD_REQUEST_LEGACY_SIZE)-byte prefix (no client-HDR +/// luminance tail), whose missing tail zero-fills to "unknown" — so a new driver keeps serving an +/// old host (see the `AddRequest` size-compatibility docs). +/// +/// # Safety +/// `request` is the framework `WDFREQUEST`. +unsafe fn read_add_request(request: WDFREQUEST) -> Option { + let mut buf: *mut core::ffi::c_void = core::ptr::null_mut(); + let mut len: usize = 0; + // SAFETY: `request` valid; `buf`/`len` are out-params written by the framework. + let st = unsafe { + call_unsafe_wdf_function_binding!( + WdfRequestRetrieveInputBuffer, + request, + control::ADD_REQUEST_LEGACY_SIZE, + &mut buf, + &mut len + ) + }; + if !nt_success(st) || buf.is_null() || len < control::ADD_REQUEST_LEGACY_SIZE { + return None; + } + let take = len.min(core::mem::size_of::()); + // Pod contract (pf-driver-proto derives Zeroable): all-zero = every optional tail field "unknown". + let mut req = pod_init!(control::AddRequest); + // SAFETY: `buf` has >= `take` readable bytes (framework contract: `len` bytes are valid); + // `req` is a Pod struct at least `take` bytes long; the ranges don't overlap (stack vs the + // framework's request buffer). + unsafe { + core::ptr::copy_nonoverlapping(buf.cast::(), (&raw mut req).cast::(), take); + } + Some(req) +} + /// Read a `Copy`/`Pod` input struct from the request's input buffer (None if too small / unavailable). /// /// # Safety diff --git a/packaging/windows/drivers/pf-vdisplay/src/edid.rs b/packaging/windows/drivers/pf-vdisplay/src/edid.rs index 9ee80e4a..cecccd90 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/edid.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/edid.rs @@ -63,8 +63,10 @@ const COLORIMETRY_DB: [u8; 4] = [ ]; /// HDR Static Metadata Data Block (CTA extended tag 0x06): EOTFs = Traditional SDR (ET_0) + SMPTE ST -/// 2084 / PQ (ET_2); Static Metadata Type 1 (SM_0). Plus the optional desired-content luminance hints -/// (~993 nit max, ~400 nit max-frame-average, ~0.05 nit min) so the block is complete. +/// 2084 / PQ (ET_2); Static Metadata Type 1 (SM_0). Plus the desired-content luminance hints +/// (~993 nit max, ~400 nit max-frame-average, ~0.05 nit min) — the BUILT-IN defaults, used when the +/// host reported no client volume; [`Edid::generate_with`] overwrites bytes 4..7 with the CLIENT +/// display's coded volume otherwise, so host apps tone-map to the panel the stream lands on. #[rustfmt::skip] const HDR_STATIC_METADATA_DB: [u8; 7] = [ 0xE6, // tag 0b111 (use-extended-tag) | length 6 @@ -76,12 +78,30 @@ const HDR_STATIC_METADATA_DB: [u8; 7] = [ 0x12, // Desired Content Min Luminance (code 18 ≈ 0.05 nits) ]; +/// The client display's luminance volume for the CTA HDR block (the [`AddRequest`] +/// (pf_driver_proto::control::AddRequest) luminance tail, same units). `max_nits == 0` = unknown +/// (an SDR client, or an un-upgraded host whose short ADD zero-fills the tail) → the built-in +/// defaults stay. +#[derive(Debug, Clone, Copy, Default)] +pub struct ClientLuminance { + /// Peak luminance, nits. `0` = unknown → keep the built-in default block. + pub max_nits: u32, + /// Max frame-average luminance, nits. `0` = unknown ("no data" on the wire). + pub max_frame_avg_nits: u32, + /// Min luminance, milli-nits. `0` = unknown/true black ("no data" on the wire). + pub min_millinits: u32, +} + #[derive(Debug, Clone, Copy)] pub struct Edid; impl Edid { /// Build the full 256-byte EDID for monitor `serial`, with both block checksums recomputed. - pub fn generate_with(serial: u32) -> Vec { + /// `lum` is the CLIENT display's luminance volume — coded into the HDR static-metadata block's + /// desired-content bytes (CTA-861.3, via the shared+unit-tested `pf_driver_proto::edid` + /// coders) so the OS/apps tone-map to the client's real panel; all-zero keeps the built-in + /// ~993-nit defaults. + pub fn generate_with(serial: u32, lum: ClientLuminance) -> Vec { let mut edid = [0u8; 256]; // Block 0: base. edid[..128].copy_from_slice(&BASE); @@ -89,7 +109,18 @@ impl Edid { // Block 1: CTA-861.3 extension (header + colorimetry + HDR static metadata; rest stays 0). edid[128..132].copy_from_slice(&CTA_HEADER); edid[132..136].copy_from_slice(&COLORIMETRY_DB); - edid[136..143].copy_from_slice(&HDR_STATIC_METADATA_DB); + let mut hdr_db = HDR_STATIC_METADATA_DB; + if lum.max_nits > 0 { + let max_code = pf_driver_proto::edid::cta_max_luminance_code(lum.max_nits); + hdr_db[4] = max_code; + hdr_db[5] = if lum.max_frame_avg_nits > 0 { + pf_driver_proto::edid::cta_max_luminance_code(lum.max_frame_avg_nits) + } else { + 0 // "no data" — valid per CTA-861.3 + }; + hdr_db[6] = pf_driver_proto::edid::cta_min_luminance_code(lum.min_millinits, max_code); + } + edid[136..143].copy_from_slice(&hdr_db); // Each 128-byte block ends in a checksum byte that makes the block sum ≡ 0 (mod 256). Self::fix_block_checksum(&mut edid, 0); Self::fix_block_checksum(&mut edid, 128); diff --git a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs index 9f2027e9..356d94a8 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs @@ -23,8 +23,8 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use pf_driver_proto::control::SetFrameChannelRequest; use pf_driver_proto::frame::{ - DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN, - SharedHeader, + AttachReject, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, + DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, check_attach, }; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Graphics::Direct3D11::{ @@ -157,9 +157,12 @@ impl FramePublisher { /// re-delivers on the next recreate — there is nothing to poll, so failure is terminal for THIS /// delivery (the host's `wait_for_attach` sees the status code and fails the session open). All /// early-return paths clean up explicitly (raw-handle style, no RAII — matches the rest of this - /// driver). + /// driver). `target_id` is the OWNING monitor's OS target id: the mapped ring must name it + /// (proto v3 binding validation — see step 3), so a cross-delivered ring can never carry this + /// monitor's frames into another client's stream. pub fn from_channel( mut channel: FrameChannel, + target_id: u32, render_luid_low: u32, render_luid_high: i32, device: &ID3D11Device, @@ -203,35 +206,70 @@ impl FramePublisher { (*header).driver_render_luid_high = render_luid_high; } - // 3. The host stamps magic==MAGIC BEFORE delivering the channel, and this channel's generation - // must match the header's CURRENT generation — a mismatch means the host recreated the ring - // again before we attached (a fresh delivery is on its way); drop this stale one. + // 3. The host stamps magic==MAGIC BEFORE delivering the channel, this channel's generation + // must match the header's CURRENT generation (a mismatch means the host recreated the ring + // again before we attached — a fresh delivery is on its way; drop this stale one), and — + // proto v3, `design/idd-push-security.md` invariant #10 — the mapped ring must NAME THIS + // MONITOR: the host stamps `target_id` before the magic, so with parallel displays a + // host-side stash cross-wire fails CLOSED here instead of publishing this monitor's frames + // into another client's ring. The shared `check_attach` (unit-tested in pf-driver-proto) + // owns the precedence: staleness first, binding second. // SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read - // atomically (Acquire) to pair with the host's Release publishes. - let (magic, header_gen) = unsafe { + // atomically (Acquire) to pair with the host's Release publishes; `target_id` is a plain + // in-bounds u32 read, stamped before the magic the Acquire load ordered us behind. + let (magic, header_gen, header_target) = unsafe { ( (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)) .load(Ordering::Acquire), (*(core::ptr::addr_of!((*header).generation) as *const AtomicU32)) .load(Ordering::Acquire), + (*header).target_id, ) }; - if magic != MAGIC || header_gen != channel.generation { - dbglog!( - "[pf-vd] frame-push(driver): dropping channel delivery (magic ok: {}, channel gen {} vs header gen {header_gen})", - magic == MAGIC, - channel.generation - ); - // SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once on - // this path. - unsafe { - let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { - Value: header.cast(), - }); - let _ = CloseHandle(map); + match check_attach( + magic, + header_gen, + header_target, + channel.generation, + target_id, + ) { + Ok(()) => {} + Err(AttachReject::Stale) => { + dbglog!( + "[pf-vd] frame-push(driver): dropping channel delivery (channel gen {} vs header gen {header_gen}) — superseded", + channel.generation + ); + // SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once + // on this path. + unsafe { + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: header.cast(), + }); + let _ = CloseHandle(map); + } + // E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt. + return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into()); + } + Err(AttachReject::BindMismatch) => { + dbglog!( + "[pf-vd] frame-push(driver): REFUSING attach — ring names target {header_target}, this monitor is {target_id} (host stash cross-wire?)" + ); + // Report the refusal through the header so the host's wait_for_attach fails the open + // LOUDLY (DRV_STATUS_BIND_FAIL) instead of timing out mute; the detail carries the + // target id the ring claims. + // SAFETY: `header`/`map` are the live mapped view + taken handle; the status writes are + // in-bounds scalar writes, then both are released exactly once on this path. + unsafe { + (*header).driver_status_detail = header_target; + (*header).driver_status = DRV_STATUS_BIND_FAIL; + let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { + Value: header.cast(), + }); + let _ = CloseHandle(map); + } + // E_INVALIDARG — the delivery itself is wrong; the caller only drops the attempt. + return Err(windows::core::HRESULT(0x8007_0057u32 as i32).into()); } - // E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt. - return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into()); } // 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works). diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index fc980107..c0eac21b 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -352,15 +352,18 @@ pub fn take_swap_chain_processor( } /// `IOCTL_ADD`: create + arrive a virtual monitor at `width`x`height`@`refresh` for `session_id`, naming it -/// by `preferred_id` (the host's per-client stable id; `0` = auto-allocate). Returns the resolved -/// `(monitor_id, target_id, adapter_luid_low, adapter_luid_high)` for the -/// [`AddReply`](pf_driver_proto::control::AddReply), or `None` on failure (no adapter yet / IddCx error). +/// by `preferred_id` (the host's per-client stable id; `0` = auto-allocate) and advertising the +/// CLIENT display's luminance volume in its EDID's CTA HDR block (`client_lum`; all-zero = the +/// built-in defaults). Returns the resolved `(monitor_id, target_id, adapter_luid_low, +/// adapter_luid_high)` for the [`AddReply`](pf_driver_proto::control::AddReply), or `None` on +/// failure (no adapter yet / IddCx error). pub fn create_monitor( session_id: u64, width: u32, height: u32, refresh: u32, preferred_id: u32, + client_lum: crate::edid::ClientLuminance, ) -> Option<(u32, u32, u32, i32)> { let adapter = crate::adapter::adapter()?; // Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart @@ -409,7 +412,7 @@ pub fn create_monitor( }; // EDID (serial = id) describes the monitor; the OS calls back into parse_monitor_description. - let mut edid = crate::edid::Edid::generate_with(id); + let mut edid = crate::edid::Edid::generate_with(id, client_lum); let mut desc = pod_init!(iddcx::IDDCX_MONITOR_DESCRIPTION); desc.Size = core::mem::size_of::() as u32; desc.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID; diff --git a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs index f19d7078..902d9c20 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs @@ -283,8 +283,12 @@ impl SwapChainProcessor { if let Some(channel) = crate::monitor::take_frame_channel(target_id) { // `if let Ok` (not a `match` with an empty `Err` arm) keeps clippy's `single_match` // happy under `-D warnings`; attach on success, drop the delivery on Err. + // `target_id` binds the attach: the mapped ring must name THIS monitor + // (proto v3 validation inside from_channel — a cross-delivered ring is + // refused, never published into). if let Ok(p) = FramePublisher::from_channel( channel, + target_id, render_luid_low, render_luid_high, &device.device,