feat(windows): parallel virtual displays — proto v3 ring binding, manager slot map, group topology (W0–W3)
windows-drivers / probe-and-proto (push) Successful in 41s
ci / web (push) Successful in 1m2s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m11s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 15s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 38s
windows-drivers / driver-build (push) Successful in 1m46s
ci / bench (push) Successful in 6m35s
docker / deploy-docs (push) Successful in 20s
windows-host / package (push) Successful in 9m37s
deb / build-publish (push) Successful in 13m48s
arch / build-publish (push) Successful in 14m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 13m34s
android / android (push) Successful in 15m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m18s
ci / rust (push) Successful in 18m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m41s
release / apple (push) Successful in 19m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m49s
flatpak / build-publish (push) Failing after 2m9s
apple / screenshots (push) Successful in 19m3s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 01:06:35 +02:00
parent 979e38523b
commit 35d97ae6ac
34 changed files with 1945 additions and 544 deletions
@@ -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, 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 launch, // a store-qualified library id to boot into a game, or None for the desktop
pin, // Some → Crypto on host-fp mismatch pin, // Some → Crypto on host-fp mismatch
identity, // owned (cert, key) PEM, or None (anonymous) identity, // owned (cert, key) PEM, or None (anonymous)
+1
View File
@@ -496,6 +496,7 @@ impl AppModel {
2, // audio_channels: stereo 2, // audio_channels: stereo
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest) crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference 0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
None, // launch: probe connect, no game None, // launch: probe connect, no game
pin, pin,
Some(identity), Some(identity),
+4
View File
@@ -491,6 +491,10 @@ async fn session(args: Args) -> Result<()> {
| punktfunk_core::quic::CODEC_AV1, | punktfunk_core::quic::CODEC_AV1,
// `--codec` soft preference (0 = auto). The host honors it when it can emit it. // `--codec` soft preference (0 = auto). The host honors it when it can emit it.
preferred_codec: args.preferred_codec, preferred_codec: args.preferred_codec,
// PUNKTFUNK_CLIENT_PEAK_NITS=<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(), .encode(),
) )
+4
View File
@@ -146,6 +146,10 @@ mod session_main {
} else { } else {
0 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, mic_enabled: settings.mic_enabled,
// The Settings preference (auto → VAAPI where it exists; the presenter // The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs). // demotes to software on boxes whose Vulkan can't import the dmabufs).
+46
View File
@@ -834,6 +834,52 @@ pub fn display_supports_hdr() -> bool {
false 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<punktfunk_core::quic::HdrMeta> {
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::<IDXGIOutput6>() 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, /// 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. /// MaxCLL 1000 / MaxFALL 400. The fallback used only until the host's real `0xCE` metadata arrives.
fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 { fn generic_hdr10_metadata() -> DXGI_HDR_METADATA_HDR10 {
+31 -11
View File
@@ -144,6 +144,7 @@ pub fn run_speed_probe(
2, // audio_channels: stereo baseline 2, // audio_channels: stereo baseline
crate::video::decodable_codecs(), crate::video::decodable_codecs(),
0, // preferred_codec: no preference 0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
None, // launch: no game None, // launch: no game
pin, pin,
Some(identity), Some(identity),
@@ -235,6 +236,34 @@ fn pump(
frame_rx: FrameRx, frame_rx: FrameRx,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
) { ) {
// 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( let connector = match NativeClient::connect(
&params.host, &params.host,
params.port, params.port,
@@ -242,24 +271,15 @@ fn pump(
params.compositor, params.compositor,
params.gamepad, params.gamepad,
params.bitrate_kbps, params.bitrate_kbps,
// Advertise 10-bit + HDR10 only when the user enabled HDR AND a display is actually in HDR if hdr_active {
// 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() {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else { } else {
if params.hdr_enabled {
tracing::info!(
"HDR enabled in settings but no HDR display detected — requesting SDR"
);
}
0 0
}, },
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto) params.preferred_codec, // the user's soft codec preference (0 = auto)
display_hdr,
None, // launch: the Windows client has no library picker yet None, // launch: the Windows client has no library picker yet
params.pin, params.pin,
Some(params.identity), Some(params.identity),
+76 -12
View File
@@ -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). /// Hold the [`ESCAPE_CHORD`] at least this long to disconnect (escalates the leave-fullscreen press).
const DISCONNECT_HOLD: Duration = Duration::from_millis(1500); 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 /// Stick deflection below this is ignored for menu navigation (0.5 of full scale — Apple
/// `GamepadMenuInput` parity; menus want deliberate flicks, not drift). /// `GamepadMenuInput` parity; menus want deliberate flicks, not drift).
const MENU_DEADZONE: u16 = 16384; const MENU_DEADZONE: u16 = 16384;
@@ -641,6 +651,13 @@ struct Worker {
menu_mode: bool, menu_mode: bool,
menu_nav: MenuNav, menu_nav: MenuNav,
menu_tx: async_channel::Sender<MenuEvent>, menu_tx: async_channel::Sender<MenuEvent>,
/// 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<Instant>,
/// 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 { 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 / /// Drain and render the feedback planes — rumble plus HID output (lightbar /
/// player LEDs / adaptive triggers) — on the active pad; this thread is their single /// 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 /// consumer. The host re-sends rumble state every ~500 ms, so the SDL duration only
@@ -1236,22 +1283,36 @@ impl Worker {
let Some(connector) = self.attached.clone() else { let Some(connector) = self.attached.clone() else {
return; 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) { while let Ok((pad, low, high)) = connector.next_rumble(Duration::ZERO) {
if pad == 0 { if pad == 0 {
if let Some((_, p)) = self.open.as_mut() { fresh = true;
// Surface a failed SDL rumble write: a swallowed error here (DualSense not in self.issue_rumble(low, high, deck);
// 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");
} }
} }
// 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) { while let Ok(hid) = connector.next_hidout(Duration::ZERO) {
let is_ds = self let is_ds = self
@@ -1318,6 +1379,9 @@ impl Worker {
menu_mode: false, menu_mode: false,
menu_nav: MenuNav::new(), menu_nav: MenuNav::new(),
menu_tx, menu_tx,
rumble_last: (0, 0),
rumble_last_at: None,
rumble_jitter: false,
} }
} }
} }
+8
View File
@@ -33,6 +33,11 @@ pub struct SessionParams {
/// the host still gates the upgrade behind its own PUNKTFUNK_10BIT policy) — `0` /// 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"). /// when the user turned HDR off in Settings ("never send me 10-bit").
pub video_caps: u8, 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<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool, pub mic_enabled: bool,
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see /// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
@@ -221,6 +226,9 @@ fn pump(
params.audio_channels, params.audio_channels,
crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1) crate::video::decodable_codecs(), // codecs FFmpeg can decode (HEVC/H.264/AV1)
params.preferred_codec, // the user's soft codec preference (0 = auto) 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.launch.clone(),
params.pin, params.pin,
Some(params.identity), Some(params.identity),
+277 -7
View File
@@ -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 /// ([`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 /// target). A v1 driver has no channel-delivery IOCTL and expects named objects, so the pairing is
/// incompatible by design. /// 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)`. /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`.
pub const fn ctl_code(func: u32) -> u32 { 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 /// `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 /// 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). /// 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)] #[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)]
pub struct AddRequest { pub struct AddRequest {
@@ -104,8 +117,26 @@ pub mod control {
/// GameStream sessions). Byte-compatible with the old `_reserved` (offset 20): an un-upgraded /// 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`]. /// driver ignores it (→ auto), which the host detects via [`AddReply::resolved_monitor_id`].
pub preferred_monitor_id: u32, 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 /// `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 }`). /// match `windows` `LUID { LowPart: u32, HighPart: i32 }`).
#[repr(C)] #[repr(C)]
@@ -193,12 +224,16 @@ pub mod control {
const _: () = { const _: () = {
use core::mem::{offset_of, size_of}; use core::mem::{offset_of, size_of};
assert!(size_of::<AddRequest>() == 24); assert!(size_of::<AddRequest>() == 40);
assert!(offset_of!(AddRequest, session_id) == 0); assert!(offset_of!(AddRequest, session_id) == 0);
assert!(offset_of!(AddRequest, width) == 8); assert!(offset_of!(AddRequest, width) == 8);
assert!(offset_of!(AddRequest, height) == 12); assert!(offset_of!(AddRequest, height) == 12);
assert!(offset_of!(AddRequest, refresh_hz) == 16); assert!(offset_of!(AddRequest, refresh_hz) == 16);
assert!(offset_of!(AddRequest, preferred_monitor_id) == 20); 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::<AddReply>() == 20); assert!(size_of::<AddReply>() == 20);
assert!(offset_of!(AddReply, adapter_luid_low) == 0); 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 /// 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; /// 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 /// 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; pub const DRV_STATUS_TEX_FAIL: u32 = 2;
/// Driver has no `ID3D11Device1` to open shared resources. /// Driver has no `ID3D11Device1` to open shared resources.
pub const DRV_STATUS_NO_DEVICE1: u32 = 3; 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`, /// 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. /// `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 width: u32,
pub height: u32, pub height: u32,
pub dxgi_format: 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`]. /// Driver-written after each copy; host loads `Acquire`. See [`FrameToken`].
pub latest: u64, pub latest: u64,
pub qpc_pts: u64, pub qpc_pts: u64,
@@ -285,6 +415,43 @@ pub mod frame {
pub driver_status_detail: u32, 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`. /// 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 /// `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 /// 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 // 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 // mapping, so a same-size field reorder would silently corrupt. Pin every offset. `target_id`
// `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 — assert that too. // (v3, the former `_pad`) after `dxgi_format` is what 8-aligns the `u64 latest` at offset 32 —
// assert that too.
const _: () = { const _: () = {
use core::mem::{offset_of, size_of}; use core::mem::{offset_of, size_of};
@@ -329,7 +497,7 @@ pub mod frame {
assert!(offset_of!(SharedHeader, width) == 16); assert!(offset_of!(SharedHeader, width) == 16);
assert!(offset_of!(SharedHeader, height) == 20); assert!(offset_of!(SharedHeader, height) == 20);
assert!(offset_of!(SharedHeader, dxgi_format) == 24); 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, latest) == 32);
assert!(offset_of!(SharedHeader, qpc_pts) == 40); assert!(offset_of!(SharedHeader, qpc_pts) == 40);
assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48); assert!(offset_of!(SharedHeader, driver_render_luid_low) == 48);
@@ -588,12 +756,52 @@ mod tests {
h.magic = frame::MAGIC; h.magic = frame::MAGIC;
h.width = 5120; h.width = 5120;
h.height = 1440; h.height = 1440;
h.target_id = 262;
let bytes = bytemuck::bytes_of(&h); let bytes = bytemuck::bytes_of(&h);
assert_eq!(bytes.len(), 64); assert_eq!(bytes.len(), 64);
let back: frame::SharedHeader = *bytemuck::from_bytes(bytes); let back: frame::SharedHeader = *bytemuck::from_bytes(bytes);
assert_eq!(back.magic, frame::MAGIC); assert_eq!(back.magic, frame::MAGIC);
assert_eq!(back.width, 5120); assert_eq!(back.width, 5120);
assert_eq!(back.height, 1440); 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] #[test]
@@ -604,12 +812,32 @@ mod tests {
height: 2160, height: 2160,
refresh_hz: 120, refresh_hz: 120,
preferred_monitor_id: 7, 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); let bytes = bytemuck::bytes_of(&req);
assert_eq!(bytes.len(), 24); assert_eq!(bytes.len(), 40);
assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req); assert_eq!(*bytemuck::from_bytes::<control::AddRequest>(bytes), req);
// preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible. // preferred_monitor_id occupies the old `_reserved` slot at offset 20 — byte-compatible.
assert_eq!(bytes[20..24], 7u32.to_le_bytes()); 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::<control::AddRequest>(&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 { let reply = control::AddReply {
adapter_luid_low: 0x1234_5678, 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] #[test]
fn guid_is_not_sudovda() { fn guid_is_not_sudovda() {
const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D; const SUDOVDA: u128 = 0xE5BC_C234_1E0C_418A_A0D4_EF8B_7501_414D;
+19
View File
@@ -341,6 +341,11 @@ pub struct Presenter {
hdr10_format: Option<vk::SurfaceFormatKHR>, hdr10_format: Option<vk::SurfaceFormatKHR>,
/// PQ frames are on screen and the swapchain is in HDR10 mode. /// PQ frames are on screen and the swapchain is in HDR10 mode.
hdr_active: bool, 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). /// `VK_EXT_hdr_metadata` device fns when the driver offers them (gamescope/KDE do).
hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>, hdr_metadata_d: Option<ash::ext::hdr_metadata::Device>,
/// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the /// The host's latest ST.2086/CLL metadata (the 0xCE plane) — pushed to the
@@ -739,6 +744,7 @@ impl Presenter {
format, format,
hdr10_format, hdr10_format,
hdr_active: false, hdr_active: false,
hdr_downgrade_warned: false,
hdr_metadata_d, hdr_metadata_d,
hdr_meta: None, hdr_meta: None,
video_format: vk::Format::R8G8B8A8_UNORM, video_format: vk::Format::R8G8B8A8_UNORM,
@@ -1066,6 +1072,19 @@ impl Presenter {
FrameInput::D3d11(d) => Some(d.color.is_pq()), FrameInput::D3d11(d) => Some(d.color.is_pq()),
}; };
if let Some(pq) = frame_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(); let want = pq && self.hdr10_format.is_some();
if want != self.hdr_active { if want != self.hdr_active {
self.set_hdr_mode(window, want)?; self.set_hdr_mode(window, want)?;
+4
View File
@@ -1374,6 +1374,10 @@ pub unsafe extern "C" fn punktfunk_connect_ex7(
crate::audio::normalize_channels(audio_channels), crate::audio::normalize_channels(audio_channels),
video_codecs, video_codecs,
preferred_codec, 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, launch,
pin, pin,
identity, identity,
+38
View File
@@ -552,6 +552,11 @@ impl NativeClient {
// it emits from these and echoes it in [`NativeClient::codec`]. // it emits from these and echoes it in [`NativeClient::codec`].
video_codecs: u8, video_codecs: u8,
preferred_codec: 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<HdrMeta>,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
@@ -618,6 +623,7 @@ impl NativeClient {
audio_channels, audio_channels,
video_codecs, video_codecs,
preferred_codec, preferred_codec,
display_hdr,
launch, launch,
pin, pin,
identity, identity,
@@ -1118,6 +1124,33 @@ impl Drop for NativeClient {
} }
} }
/// Test/A-B hatch shared by the client shells: `PUNKTFUNK_CLIENT_PEAK_NITS=<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<HdrMeta> {
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 { struct WorkerArgs {
host: String, host: String,
port: u16, port: u16,
@@ -1129,6 +1162,7 @@ struct WorkerArgs {
audio_channels: u8, audio_channels: u8,
video_codecs: u8, video_codecs: u8,
preferred_codec: u8, preferred_codec: u8,
display_hdr: Option<HdrMeta>,
launch: Option<String>, launch: Option<String>,
pin: Option<[u8; 32]>, pin: Option<[u8; 32]>,
identity: Option<(String, String)>, identity: Option<(String, String)>,
@@ -1171,6 +1205,7 @@ async fn worker_main(args: WorkerArgs) {
audio_channels, audio_channels,
video_codecs, video_codecs,
preferred_codec, preferred_codec,
display_hdr,
launch, launch,
pin, pin,
identity, identity,
@@ -1250,6 +1285,9 @@ async fn worker_main(args: WorkerArgs) {
// resolves the emitted codec from these and reports it in `Welcome::codec`. // resolves the emitted codec from these and reports it in `Welcome::codec`.
video_codecs, video_codecs,
preferred_codec, 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(), .encode(),
) )
+36 -21
View File
@@ -330,14 +330,16 @@ pub struct HdrMeta {
/// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`]. /// see [`HdrMeta`]). Next tag after [`HIDOUT_MAGIC`].
pub const HDR_META_MAGIC: u8 = 0xCE; pub const HDR_META_MAGIC: u8 = 0xCE;
/// Wire length of an [`HDR_META_MAGIC`] datagram: tag + 6×u16 primaries + 2×u16 white + 2×u32 /// Wire length of an [`HdrMeta`] body (no tag byte): 6×u16 primaries + 2×u16 white + 2×u32
/// luminance + 2×u16 CLL/FALL = 29 bytes. /// luminance + 2×u16 CLL/FALL = 28 bytes. Shared by the [`HDR_META_MAGIC`] datagram (which
const HDR_META_LEN: usize = 1 + 12 + 4 + 8 + 4; /// 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. /// Wire length of an [`HDR_META_MAGIC`] datagram: tag + body = 29 bytes.
pub fn encode_hdr_meta_datagram(m: &HdrMeta) -> Vec<u8> { const HDR_META_LEN: usize = 1 + HDR_META_BODY_LEN;
let mut b = Vec::with_capacity(HDR_META_LEN);
b.push(HDR_META_MAGIC); /// 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<u8>) {
for p in m.display_primaries.iter() { for p in m.display_primaries.iter() {
b.extend_from_slice(&p[0].to_le_bytes()); b.extend_from_slice(&p[0].to_le_bytes());
b.extend_from_slice(&p[1].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<u8> {
b.extend_from_slice(&m.min_display_mastering_luminance.to_le_bytes()); 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_cll.to_le_bytes());
b.extend_from_slice(&m.max_fall.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<u8> {
let mut b = Vec::with_capacity(HDR_META_LEN);
b.push(HDR_META_MAGIC);
write_hdr_meta_body(m, &mut b);
b b
} }
@@ -357,20 +385,7 @@ pub fn decode_hdr_meta_datagram(b: &[u8]) -> Option<HdrMeta> {
if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC { if b.len() < HDR_META_LEN || b[0] != HDR_META_MAGIC {
return None; return None;
} }
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]); Some(read_hdr_meta_body(&b[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),
})
} }
/// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after /// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after
+29 -7
View File
@@ -2,6 +2,7 @@
//! (`CTL_MAGIC` + type byte), including the pairing-ceremony messages. Wire codecs only //! (`CTL_MAGIC` + type byte), including the pairing-ceremony messages. Wire codecs only
//! — no transport state. //! — no transport state.
use super::datagram::HdrMeta;
use super::{CTL_MAGIC, MAGIC}; use super::{CTL_MAGIC, MAGIC};
use crate::config::{ use crate::config::{
CompositorPref, Config, FecConfig, FecScheme, GamepadPref, Mode, ProtocolPhase, Role, 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`]. /// [`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`). /// Appended after `video_codecs` as a single trailing byte. Omitted by older clients (→ `0`).
pub preferred_codec: u8, 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<HdrMeta>,
} }
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream. /// [`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 ac_present = self.audio_channels != 2;
let vcodecs_present = self.video_codecs != 0; let vcodecs_present = self.video_codecs != 0;
let pref_present = self.preferred_codec != 0; let pref_present = self.preferred_codec != 0;
let hdr_present = self.display_hdr.is_some();
let need_placeholders = 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) { match (&self.name, &self.launch) {
(None, None) if !need_placeholders => {} (None, None) if !need_placeholders => {}
(name, _) => { (name, _) => {
@@ -688,21 +701,25 @@ impl Hello {
} }
// video_caps: single trailing byte. Emitted when non-zero OR when a later field follows (so // 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). // 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); b.push(self.video_caps);
} }
// audio_channels: emitted when non-stereo OR a later field follows. // 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); b.push(self.audio_channels);
} }
// video_codecs: emitted when non-zero OR preferred_codec follows. // video_codecs: emitted when non-zero OR a later field follows.
if vcodecs_present || pref_present { if vcodecs_present || pref_present || hdr_present {
b.push(self.video_codecs); b.push(self.video_codecs);
} }
// preferred_codec: single trailing byte. Last field; omitted when `0` (no preference). // preferred_codec: emitted when non-zero OR display_hdr follows.
if pref_present { if pref_present || hdr_present {
b.push(self.preferred_codec); 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 b
} }
@@ -766,6 +783,11 @@ impl Hello {
video_codecs: b.get(tail + 2).copied().unwrap_or(0), video_codecs: b.get(tail + 2).copied().unwrap_or(0),
// `0` = no preference; the host decides by precedence. // `0` = no preference; the host decides by precedence.
preferred_codec: b.get(tail + 3).copied().unwrap_or(0), 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),
}) })
} }
} }
+70
View File
@@ -119,6 +119,7 @@ fn codec_negotiation_and_back_compat() {
audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders audio_channels: 2, // stereo — forces the video_caps/audio_channels placeholders
video_codecs: CODEC_H264 | CODEC_HEVC, video_codecs: CODEC_H264 | CODEC_HEVC,
preferred_codec: CODEC_H264, preferred_codec: CODEC_H264,
display_hdr: None,
}; };
let enc = h.encode(); let enc = h.encode();
let dec = Hello::decode(&enc).unwrap(); let dec = Hello::decode(&enc).unwrap();
@@ -234,6 +235,7 @@ fn hello_start_roundtrip() {
audio_channels: 2, audio_channels: 2,
video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip video_codecs: CODEC_H264 | CODEC_HEVC, // exercise the codec bitfield roundtrip
preferred_codec: CODEC_HEVC, preferred_codec: CODEC_HEVC,
display_hdr: None,
}; };
assert_eq!(Hello::decode(&h.encode()).unwrap(), h); assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
let s = Start { let s = Start {
@@ -316,6 +318,7 @@ fn hello_welcome_compositor_back_compat() {
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0, preferred_codec: 0,
display_hdr: None,
}; };
let enc = h.encode(); let enc = h.encode();
assert_eq!(enc.len(), 26); assert_eq!(enc.len(), 26);
@@ -430,6 +433,7 @@ fn hello_name_roundtrip_and_back_compat() {
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0, preferred_codec: 0,
display_hdr: None,
}; };
let enc = base.encode(); let enc = base.encode();
assert_eq!( assert_eq!(
@@ -480,6 +484,7 @@ fn hello_launch_roundtrip_and_back_compat() {
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0, preferred_codec: 0,
display_hdr: None,
}; };
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic. // launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
let with_launch = Hello { 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')); 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] #[test]
fn reconfigure_roundtrip() { fn reconfigure_roundtrip() {
let rq = Reconfigure { let rq = Reconfigure {
@@ -786,6 +855,7 @@ fn control_messages_disjoint_from_hello() {
audio_channels: 2, audio_channels: 2,
video_codecs: 0, video_codecs: 0,
preferred_codec: 0, preferred_codec: 0,
display_hdr: None,
} }
.encode(); .encode();
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
@@ -30,7 +30,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use windows::core::{w, Interface, PCWSTR, PWSTR}; use windows::core::{w, Interface, PCWSTR, PWSTR};
use windows::Win32::Foundation::{ use windows::Win32::Foundation::{
DuplicateHandle, DUPLICATE_CLOSE_SOURCE, DUPLICATE_HANDLE_OPTIONS, DUPLICATE_SAME_ACCESS, 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::{ use windows::Win32::Graphics::Direct3D11::{
ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D, ID3D11Device, ID3D11DeviceContext, ID3D11ShaderResourceView, ID3D11Texture2D,
@@ -59,13 +59,14 @@ use windows::Win32::System::Threading::{
use windows::Win32::UI::Input::KeyboardAndMouse::{ use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_MOUSE, MOUSEEVENTF_MOVE, MOUSEINPUT, 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 // 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 // `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` it, so a layout/code drift is a compile error (the proto has `const` size asserts).
use frame::{ use frame::{
SharedHeader, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, SharedHeader, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
VERSION, DRV_STATUS_TEX_FAIL, MAGIC, RING_LEN, VERSION,
}; };
/// `DXGI_SHARED_RESOURCE_READ | _WRITE` for `CreateSharedHandle`/`OpenSharedResourceByName`. Local (not /// `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 /// Nudge DWM into composing THE TARGET virtual display. DWM presents a display only when something
/// `SendInput`. DWM presents a display only when something DIRTIES it — an idle desktop never does, /// DIRTIES it — an idle desktop never does, so a freshly-attached ring (session open, or a
/// so a freshly-attached ring (session open, or a mid-session ring recreate) can sit at E_PENDING /// mid-session ring recreate) can sit at E_PENDING with no first frame even though everything is
/// with no first frame even though everything is healthy. pf-vdisplay implements no hardware-cursor /// healthy. pf-vdisplay implements no hardware-cursor plane, so a cursor move is composited into
/// plane, so a cursor move is composited into the frame — a guaranteed real present onto the IDD /// the frame — a guaranteed real present onto the IDD swap-chain (empirically what
/// swap-chain (empirically what `punktfunk-probe --input-test` always relied on). Net-zero: the /// `punktfunk-probe --input-test` always relied on).
/// 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 /// The cursor only dirties the display it is ON — proven on-glass in the Stage-W3 two-display
/// a fresh compose just happened anyway. /// validation: display B's session-open kicks wiggled the cursor on display A and B never composed
fn kick_dwm_compose() { /// 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
// 600800 ms per-capturer schedules.
static LAST_KICK: Mutex<Option<Instant>> = 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 { let mk = |dx: i32| INPUT {
r#type: INPUT_MOUSE, r#type: INPUT_MOUSE,
Anonymous: INPUT_0 { Anonymous: INPUT_0 {
@@ -1015,6 +1067,13 @@ impl IddPushCapturer {
// Ring format = the display's composition format (FP16 in HDR, BGRA in SDR). The driver // 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. // reads this into its `ring_format` and drops any surface that doesn't match.
(*header).dxgi_format = ring_fmt.0 as u32; (*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. // Frame-ready event (auto-reset) — UNNAMED, like everything on this channel.
let event = CreateEventW(Some(&sa), false, false, PCWSTR::null()) 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, /// 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. /// so this does not false-fail a normal (even idle) open; no frame within the window = genuinely broken.
fn wait_for_attach(&self) -> Result<()> { 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 ringmonitor cross-wire (bug); failing the open",
self.target_id
);
}
let deadline = Instant::now() + Duration::from_secs(4); let deadline = Instant::now() + Duration::from_secs(4);
// Compose-kick schedule: DWM only presents a display something DIRTIED, so on an idle // 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 // 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)" 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. // 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 { if st == DRV_STATUS_OPENED && frame::FrameToken::unpack(self.latest()).seq != 0 {
return Ok(()); return Ok(());
} }
if Instant::now() >= next_kick { if Instant::now() >= next_kick {
kick_dwm_compose(); kick_dwm_compose(self.target_id);
next_kick = Instant::now() + Duration::from_millis(800); next_kick = Instant::now() + Duration::from_millis(800);
} }
if Instant::now() > deadline { if Instant::now() > deadline {
@@ -1227,6 +1311,11 @@ impl IddPushCapturer {
DRV_STATUS_NO_DEVICE1 => { DRV_STATUS_NO_DEVICE1 => {
tracing::error!("IDD push: driver has no ID3D11Device1 to open shared resources") 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"), 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.elapsed() > Duration::from_millis(800)
{ {
self.last_kick = Instant::now(); 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, // Driver-death watch (the SDR path has no other signal): a dead WUDFHost stops publishing,
+17
View File
@@ -1054,6 +1054,23 @@ pub fn windows_codec_support() -> CodecSupport {
caps 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 // 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 // ffmpeg, software) and `encode/linux/` (NVENC/CUDA + VAAPI); `#[path]` keeps the
// `crate::encode::*` module names flat. // `crate::encode::*` module names flat.
@@ -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 = 23) — 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). /// 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 /// 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 /// 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 /// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
/// tear down and re-init NVENC against it. /// tear down and re-init NVENC against it.
init_device: *mut c_void, init_device: *mut c_void,
/// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 23
/// 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`), // SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
@@ -469,6 +512,7 @@ impl NvencD3d11Encoder {
custom_vbv: false, custom_vbv: false,
last_rfi_range: None, last_rfi_range: None,
init_device: ptr::null_mut(), 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_bitstream_buffer)(self.encoder, bs);
} }
let _ = (api().destroy_encoder)(self.encoder); 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.regs.clear(); // drops the texture clones, releasing our refs
self.bitstreams.clear(); self.bitstreams.clear();
self.pending.clear(); self.pending.clear();
@@ -1004,6 +1051,11 @@ impl NvencD3d11Encoder {
} }
}; };
self.encoder = enc; 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 { if self.bitrate_bps < requested_bps {
tracing::info!( tracing::info!(
requested_mbps = requested_bps / 1_000_000, requested_mbps = requested_bps / 1_000_000,
@@ -1661,7 +1713,7 @@ mod tests {
fn encode_pattern(chroma: ChromaFormat, path: &str) { fn encode_pattern(chroma: ChromaFormat, path: &str) {
const W: u32 = 1280; const W: u32 = 1280;
const H: u32 = 720; 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). // is checked before use; the texture/device outlive the encoder (dropped at scope end).
unsafe { unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory"); let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
@@ -299,16 +299,19 @@ fn open_gs_virtual_source(
// the native session's ring (newest-wins) — each plane could freeze the other. GameStream has // 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 // 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 — // 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")] #[cfg(target_os = "windows")]
let _idd_setup_guard = matches!( let _idd_setup_guard = matches!(
crate::session_plan::CaptureBackend::resolve(), crate::session_plan::CaptureBackend::resolve(),
crate::session_plan::CaptureBackend::IddPush crate::session_plan::CaptureBackend::IddPush
) )
.then(|| { .then(|| {
crate::vdisplay::manager::vdm().begin_idd_setup(std::sync::Arc::new( crate::vdisplay::manager::vdm().begin_idd_setup(
std::sync::atomic::AtomicBool::new(false), 0,
)) std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
)
}); });
let vout = crate::vdisplay::registry::acquire( let vout = crate::vdisplay::registry::acquire(
&mut vd, &mut vd,
+32
View File
@@ -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 / /// 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 /// MaxFALL 400) — the baseline a host sends until it reads the source display's real mastering
/// metadata, and the values clients used to hardcode. /// 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 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] #[test]
fn clamps_out_of_range() { fn clamps_out_of_range() {
let m = hdr_meta_from_display( let m = hdr_meta_from_display(
+45 -9
View File
@@ -1370,9 +1370,15 @@ async fn serve_session(
// GetDesc1) as soon as capture starts and re-sends it on keyframes; the client applies the // 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. // latest it receives. This baseline covers the synthetic source and the pre-capture gap.
if welcome.color.is_hdr() { 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()); 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 // 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 stop_stream = stop.clone();
let quit_stream = quit.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 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 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 // 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, stats: stats_dp,
client_label, client_label,
launch: launch_for_dp, 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 /// 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. /// via `set_launch_command`, or spawned into the live session once capture is up.
launch: Option<String>, launch: Option<String>,
/// 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<punktfunk_core::quic::HdrMeta>,
} }
fn virtual_stream(ctx: SessionContext) -> Result<()> { fn virtual_stream(ctx: SessionContext) -> Result<()> {
@@ -3217,6 +3233,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
stats, stats,
client_label, client_label,
launch, launch,
client_hdr,
} = ctx; } = ctx;
tracing::info!( tracing::info!(
compositor = compositor.id(), 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 // 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). // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates).
vd.set_client_identity(endpoint::peer_fingerprint(&conn)); 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 — // 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" // 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 // (⌘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; // 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 // 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. // (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")] #[cfg(target_os = "windows")]
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) let _idd_setup_guard =
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); (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) = 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)?; 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). // 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_repeat = 0;
diag_at = std::time::Instant::now(); diag_at = std::time::Instant::now();
} }
// The source's static HDR mastering metadata (Windows GetDesc1; None on Linux/SDR) is the // The source's static HDR mastering metadata is the single source of truth: hand it to the
// single source of truth: hand it to the encoder (in-band SEI on keyframes) and, when it // encoder (in-band SEI on keyframes) and, when it changes, to the client (0xCE). Re-sent on
// changes, to the client (0xCE). Re-sent on each keyframe below so a dropped best-effort // each keyframe below so a dropped best-effort datagram converges within a GOP. PRESENCE is
// datagram converges within a GOP. // the capturer's call (Some iff the virtual display is in HDR mode); the VALUE prefers the
let hdr_meta = capturer.hdr_meta(); // 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); enc.set_hdr_meta(hdr_meta);
let mut resend_meta = hdr_meta != last_hdr_meta; let mut resend_meta = hdr_meta != last_hdr_meta;
if resend_meta { if resend_meta {
@@ -4840,6 +4873,7 @@ mod tests {
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs (0 → HEVC-only) 0, // video_codecs (0 → HEVC-only)
0, // preferred_codec (auto) 0, // preferred_codec (auto)
None, // display_hdr
None, // launch None, // launch
None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client None, // pin: TOFU — the operator's approval (not a PIN) authorizes this client
Some((cert, key)), Some((cert, key)),
@@ -4906,6 +4940,7 @@ mod tests {
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs 0, // video_codecs
0, // preferred_codec 0, // preferred_codec
None, // display_hdr
None, // launch None, // launch
None, None,
None, None,
@@ -4934,6 +4969,7 @@ mod tests {
2, // audio_channels (stereo) 2, // audio_channels (stereo)
0, // video_codecs 0, // video_codecs
0, // preferred_codec 0, // preferred_codec
None, // display_hdr
None, // launch None, // launch
Some(host_fp), Some(host_fp),
Some((cert.clone(), key.clone())), Some((cert.clone(), key.clone())),
+9
View File
@@ -132,6 +132,15 @@ pub trait VirtualDisplay: Send {
/// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach; /// leases live in the `VirtualDisplayManager`, which the registry's quit plumbing does not reach;
/// Linux backends get the flag through `registry::acquire`). /// Linux backends get the flag through `registry::acquire`).
fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {} fn set_quit_flag(&mut self, _quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {}
/// 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<punktfunk_core::quic::HdrMeta>) {}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) — /// 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 /// 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 /// registry reads it right after `create` to key the display's group **arrangement** (manual
@@ -92,27 +92,57 @@ pub fn decide(
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when /// 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), with the **Windows default applied**. On Windows `separate` — including the
/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the /// unconfigured default — still resolves to **`reject`** UNLESS the Stage-W3 validation hatch
/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes /// `PUNKTFUNK_WIN_SEPARATE=1` is set (`design/windows-parallel-virtual-displays.md` §4.3 — the
/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd /// default flips to real `separate` in W5, after the on-glass matrix is green).
/// 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. /// 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 { pub fn effective_conflict() -> ModeConflict {
let conflict = policy::prefs() let conflict = policy::prefs()
.configured_effective() .configured_effective()
.map(|e| e.mode_conflict) .map(|e| e.mode_conflict)
.unwrap_or(ModeConflict::Separate); .unwrap_or(ModeConflict::Separate);
#[cfg(windows)] #[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; return ModeConflict::Reject;
} }
conflict conflict
} }
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`] /// 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 { 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 /// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client
+12 -12
View File
@@ -108,11 +108,13 @@ pub fn acquire(
pub fn snapshot() -> Snapshot { pub fn snapshot() -> Snapshot {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0, // Windows slots (Stage W1): one group — the shared desktop — with the manager's slot list in
// origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not // acquire order (`display_index`), each at its group-layout position. `identity_slot` is the
// surfaced here yet. // slot key (`None` for the anonymous slot 0).
let displays = super::manager::snapshot() let displays = super::manager::snapshot()
.map(|i| DisplayInfo { .into_iter()
.enumerate()
.map(|(idx, i)| DisplayInfo {
slot: i.gen, slot: i.gen,
backend: i.backend.to_string(), backend: i.backend.to_string(),
mode: i.mode, mode: i.mode,
@@ -121,12 +123,11 @@ pub fn snapshot() -> Snapshot {
sessions: i.sessions, sessions: i.sessions,
client: None, client: None,
group: 1, group: 1,
display_index: 0, display_index: idx as u32,
position: (0, 0), position: i.position,
identity_slot: None, identity_slot: (i.slot_id != 0).then_some(i.slot_id),
topology: topology_str(), topology: topology_str(),
}) })
.into_iter()
.collect(); .collect();
Snapshot { displays } Snapshot { displays }
} }
@@ -149,10 +150,9 @@ pub fn snapshot() -> Snapshot {
pub fn release(slot: Option<u64>) -> usize { pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one // Windows slots (Stage W1): `slot` selects one kept monitor by its gen stamp
// lingering monitor if present. (Multi-monitor gives `slot` meaning later.) // ([`DisplayInfo::slot`]); `None` releases every kept one.
let _ = slot; super::manager::force_release(slot)
usize::from(super::manager::force_release())
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
File diff suppressed because it is too large Load Diff
@@ -432,14 +432,33 @@ impl VdisplayDriver for PfVdisplayDriver {
mode: Mode, mode: Mode,
render_luid: Option<LUID>, render_luid: Option<LUID>,
preferred_monitor_id: u32, preferred_monitor_id: u32,
client_hdr: Option<punktfunk_core::quic::HdrMeta>,
) -> Result<AddedMonitor> { ) -> Result<AddedMonitor> {
let session_id = next_session_id(); 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 { let add = control::AddRequest {
session_id, session_id,
width: mode.width, width: mode.width,
height: mode.height, height: mode.height,
refresh_hz: mode.refresh_hz, refresh_hz: mode.refresh_hz,
preferred_monitor_id, 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 // 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. // 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, target_id: reply.target_id,
luid, luid,
wudf_pid: reply.wudf_pid, 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). /// 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`. /// Set by [`set_client_identity`](VirtualDisplay::set_client_identity) before `create`.
client_fp: Option<[u8; 32]>, 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<punktfunk_core::quic::HdrMeta>,
/// The session's deliberate-quit flag (`None` = no signal → the linger policy applies). Set by /// 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 /// [`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. /// 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()?; super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?;
Ok(Self { Ok(Self {
client_fp: None, client_fp: None,
client_hdr: None,
quit: None, quit: None,
}) })
} }
@@ -615,12 +641,16 @@ impl VirtualDisplay for PfVdisplayDisplay {
self.client_fp = fingerprint; self.client_fp = fingerprint;
} }
fn set_client_hdr(&mut self, hdr: Option<punktfunk_core::quic::HdrMeta>) {
self.client_hdr = hdr;
}
fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) { fn set_quit_flag(&mut self, quit: std::sync::Arc<std::sync::atomic::AtomicBool>) {
self.quit = Some(quit); self.quit = Some(quit);
} }
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> { fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
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())
} }
} }
+107 -17
View File
@@ -386,17 +386,18 @@ unsafe fn query_active_config() -> Option<SavedConfig> {
Some((paths, modes)) Some((paths, modes))
} }
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would /// Count currently-ACTIVE display paths whose target id is not in `keep_target_ids` — i.e. displays
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually /// that would still be lit besides the managed virtual set. `None` on query failure. Used to VERIFY
/// took, and (in the `primary` topology) to detect a physical that is ALREADY active so we can skip a /// isolation actually took, and (in the `primary` topology) to detect a physical that is ALREADY
/// force-EXTEND that would reset its refresh. /// 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<u32> { pub(crate) unsafe fn count_other_active(keep_target_ids: &[u32]) -> Option<u32> {
let (paths, _) = query_active_config()?; let (paths, _) = query_active_config()?;
Some( Some(
paths paths
.iter() .iter()
.filter(|p| { .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, .count() as u32,
) )
@@ -406,24 +407,28 @@ pub(crate) unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't /// 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 / /// 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)` /// 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 /// sees every active path; we deactivate all of them EXCEPT the managed virtual target **set**
/// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same /// (`design/display-management.md` §6.1: "exclusive" means the managed set stays active — with
/// way (CCD). Returns the original active config to restore on teardown. /// 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 // 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). // (it operates on real OS target ids — a pf-vdisplay monitor's target_id qualifies).
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> { pub(crate) unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfig> {
// Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes. // Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes.
let saved = query_active_config()?; let saved = query_active_config()?;
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical // 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 // 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. // correctness depends on this — the lock screen must not land on a stray panel while we stream.
for attempt in 1..=4u32 { for attempt in 1..=4u32 {
let (mut paths, modes) = query_active_config()?; let (mut paths, modes) = query_active_config()?;
let mut others = 0u32; let mut others = 0u32;
for p in paths.iter_mut() { for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id { if keep_target_ids.contains(&p.targetInfo.id) {
continue; continue;
} }
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 { if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
@@ -446,19 +451,104 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags); let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags);
// VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query // VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query
// and confirm no non-keep display survived. Only then is the virtual truly the sole desktop. // and confirm no non-keep display survived. Only then is the virtual set truly the sole desktop.
let survivors = count_other_active(keep_target_id).unwrap_or(0); let survivors = count_other_active(keep_target_ids).unwrap_or(0);
if survivors == 0 { if survivors == 0 {
tracing::info!("display isolate (CCD): target {keep_target_id} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})"); tracing::info!("display isolate (CCD): target set {keep_target_ids:?} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
return Some(saved); return Some(saved);
} }
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"); tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
std::thread::sleep(std::time::Duration::from_millis(250)); std::thread::sleep(std::time::Duration::from_millis(250));
} }
tracing::error!("display isolate (CCD): FAILED to isolate target {keep_target_id} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)"); tracing::error!("display isolate (CCD): FAILED to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)");
Some(saved) Some(saved)
} }
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
/// region lives in the desktop coordinate space. `None` while the target isn't an active path.
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
/// cursor sits on ONE of them, and a cursor wiggle only dirties that one — a sibling display's
/// kick must first know where to send the cursor (Stage W3 on-glass finding).
pub(crate) unsafe fn source_desktop_rect(target_id: u32) -> 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 /// **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 /// 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 /// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source
+14 -5
View File
@@ -295,6 +295,13 @@
#define HDR_META_MAGIC 206 #define HDR_META_MAGIC 206
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Per-AU host-timing datagram tag, host → client (see [`HostTiming`]). Next tag after // 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 // [`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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC // [`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 // Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`) AND its user turned 4:4:4 on (a
// bit is set, the host opted in (`PUNKTFUNK_444`), the codec is HEVC, **and** the GPU/driver // client-side setting, default OFF — the per-session policy switch). The host emits 4:4:4 ONLY
// actually supports a 4:4:4 encode (probed) — otherwise the session stays 4:2:0 and // when this bit is set, the host allows it (`PUNKTFUNK_444`, default on), the codec is HEVC,
// [`Welcome::chroma_format`] reflects the real resolved value. Independent of 10-bit/HDR (4:4:4 is a // **and** the GPU/driver actually supports a 4:4:4 encode (probed) — otherwise the session stays
// chroma decision, bit depth is a depth decision; the two may combine where the hardware allows). // 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 #define VIDEO_CAP_444 4
#endif #endif
+16
View File
@@ -84,6 +84,22 @@ finish-args:
- --share=network - --share=network
# --- persistent client identity / pairing store (shared with punktfunk-probe) --- # --- persistent client identity / pairing store (shared with punktfunk-probe) ---
- --filesystem=~/.config/punktfunk:create # client-{cert,key}.pem, known-hosts, settings - --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: build-options:
append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin append-path: /usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm20/bin
@@ -127,7 +127,7 @@ unsafe fn set_render_adapter(request: WDFREQUEST) {
/// `request` is the framework `WDFREQUEST`. /// `request` is the framework `WDFREQUEST`.
unsafe fn add(request: WDFREQUEST) { unsafe fn add(request: WDFREQUEST) {
// SAFETY: `request` is the framework WDFREQUEST. // SAFETY: `request` is the framework WDFREQUEST.
let Some(req) = (unsafe { read_input::<control::AddRequest>(request) }) else { let Some(req) = (unsafe { read_add_request(request) }) else {
complete(request, STATUS_INVALID_PARAMETER); complete(request, STATUS_INVALID_PARAMETER);
return; return;
}; };
@@ -141,6 +141,11 @@ unsafe fn add(request: WDFREQUEST) {
req.height, req.height,
req.refresh_hz, req.refresh_hz,
req.preferred_monitor_id, 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 { ) else {
complete(request, STATUS_NOT_FOUND); complete(request, STATUS_NOT_FOUND);
return; return;
@@ -207,6 +212,41 @@ unsafe fn remove(request: WDFREQUEST) {
complete(request, STATUS_SUCCESS); 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<control::AddRequest> {
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::<control::AddRequest>());
// 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::<u8>(), (&raw mut req).cast::<u8>(), take);
}
Some(req)
}
/// Read a `Copy`/`Pod` input struct from the request's input buffer (None if too small / unavailable). /// Read a `Copy`/`Pod` input struct from the request's input buffer (None if too small / unavailable).
/// ///
/// # Safety /// # Safety
@@ -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 /// 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 /// 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) so the block is complete. /// (~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] #[rustfmt::skip]
const HDR_STATIC_METADATA_DB: [u8; 7] = [ const HDR_STATIC_METADATA_DB: [u8; 7] = [
0xE6, // tag 0b111 (use-extended-tag) | length 6 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) 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)] #[derive(Debug, Clone, Copy)]
pub struct Edid; pub struct Edid;
impl Edid { impl Edid {
/// Build the full 256-byte EDID for monitor `serial`, with both block checksums recomputed. /// Build the full 256-byte EDID for monitor `serial`, with both block checksums recomputed.
pub fn generate_with(serial: u32) -> Vec<u8> { /// `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<u8> {
let mut edid = [0u8; 256]; let mut edid = [0u8; 256];
// Block 0: base. // Block 0: base.
edid[..128].copy_from_slice(&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). // Block 1: CTA-861.3 extension (header + colorimetry + HDR static metadata; rest stays 0).
edid[128..132].copy_from_slice(&CTA_HEADER); edid[128..132].copy_from_slice(&CTA_HEADER);
edid[132..136].copy_from_slice(&COLORIMETRY_DB); 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). // 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, 0);
Self::fix_block_checksum(&mut edid, 128); Self::fix_block_checksum(&mut edid, 128);
@@ -23,8 +23,8 @@ use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use pf_driver_proto::control::SetFrameChannelRequest; use pf_driver_proto::control::SetFrameChannelRequest;
use pf_driver_proto::frame::{ use pf_driver_proto::frame::{
DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, DRV_STATUS_TEX_FAIL, FrameToken, MAGIC, RING_LEN, AttachReject, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED,
SharedHeader, DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, check_attach,
}; };
use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::Graphics::Direct3D11::{ 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 /// 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 /// 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 /// 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( pub fn from_channel(
mut channel: FrameChannel, mut channel: FrameChannel,
target_id: u32,
render_luid_low: u32, render_luid_low: u32,
render_luid_high: i32, render_luid_high: i32,
device: &ID3D11Device, device: &ID3D11Device,
@@ -203,27 +206,41 @@ impl FramePublisher {
(*header).driver_render_luid_high = render_luid_high; (*header).driver_render_luid_high = render_luid_high;
} }
// 3. The host stamps magic==MAGIC BEFORE delivering the channel, and this channel's generation // 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 // 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. // 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 // 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. // atomically (Acquire) to pair with the host's Release publishes; `target_id` is a plain
let (magic, header_gen) = unsafe { // 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)) (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32))
.load(Ordering::Acquire), .load(Ordering::Acquire),
(*(core::ptr::addr_of!((*header).generation) as *const AtomicU32)) (*(core::ptr::addr_of!((*header).generation) as *const AtomicU32))
.load(Ordering::Acquire), .load(Ordering::Acquire),
(*header).target_id,
) )
}; };
if magic != MAGIC || header_gen != channel.generation { match check_attach(
magic,
header_gen,
header_target,
channel.generation,
target_id,
) {
Ok(()) => {}
Err(AttachReject::Stale) => {
dbglog!( dbglog!(
"[pf-vd] frame-push(driver): dropping channel delivery (magic ok: {}, channel gen {} vs header gen {header_gen})", "[pf-vd] frame-push(driver): dropping channel delivery (channel gen {} vs header gen {header_gen}) — superseded",
magic == MAGIC,
channel.generation channel.generation
); );
// SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once on // SAFETY: `header`/`map` are the live mapped view + taken handle; unmapped + closed once
// this path. // on this path.
unsafe { unsafe {
let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS { let _ = UnmapViewOfFile(MEMORY_MAPPED_VIEW_ADDRESS {
Value: header.cast(), Value: header.cast(),
@@ -233,6 +250,27 @@ impl FramePublisher {
// E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt. // E_BOUNDS — stand-in for "stale delivery"; the caller only drops the attempt.
return Err(windows::core::HRESULT(0x8000_000Bu32 as i32).into()); 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());
}
}
// 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works). // 4. The frame-ready event (duplicated with the host handle's full access, so SetEvent works).
let event = FrameChannel::take(&mut channel.event); let event = FrameChannel::take(&mut channel.event);
@@ -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 /// `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 /// by `preferred_id` (the host's per-client stable id; `0` = auto-allocate) and advertising the
/// `(monitor_id, target_id, adapter_luid_low, adapter_luid_high)` for the /// CLIENT display's luminance volume in its EDID's CTA HDR block (`client_lum`; all-zero = the
/// [`AddReply`](pf_driver_proto::control::AddReply), or `None` on failure (no adapter yet / IddCx error). /// 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( pub fn create_monitor(
session_id: u64, session_id: u64,
width: u32, width: u32,
height: u32, height: u32,
refresh: u32, refresh: u32,
preferred_id: u32, preferred_id: u32,
client_lum: crate::edid::ClientLuminance,
) -> Option<(u32, u32, u32, i32)> { ) -> Option<(u32, u32, u32, i32)> {
let adapter = crate::adapter::adapter()?; let adapter = crate::adapter::adapter()?;
// Single identity per session (E1): if the host re-ADDs a still-live `session_id` (it shouldn't), depart // 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. // 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); let mut desc = pod_init!(iddcx::IDDCX_MONITOR_DESCRIPTION);
desc.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_DESCRIPTION>() as u32; desc.Size = core::mem::size_of::<iddcx::IDDCX_MONITOR_DESCRIPTION>() as u32;
desc.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID; desc.Type = iddcx::IDDCX_MONITOR_DESCRIPTION_TYPE::IDDCX_MONITOR_DESCRIPTION_TYPE_EDID;
@@ -283,8 +283,12 @@ impl SwapChainProcessor {
if let Some(channel) = crate::monitor::take_frame_channel(target_id) { 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` // `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. // 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( if let Ok(p) = FramePublisher::from_channel(
channel, channel,
target_id,
render_luid_low, render_luid_low,
render_luid_high, render_luid_high,
&device.device, &device.device,