From c06ee55b61350c02394f6cc9cbf3dd0203898d76 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 7 Aug 2026 15:34:12 +0200 Subject: [PATCH] diag(vkdecode): --probe-decode reports what the driver says about video images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Intel Arc refusal moved one step down the caps query and stopped again: the coincide NV12 entry does not advertise SAMPLED. That sentence is punktfunk's, not the driver's, and the last two times a conclusion was drawn from a sentence of ours the conclusion was wrong. So --probe-decode now prints the driver's own answers instead. For every profile the client can negotiate (H.264 High, H.265 Main and Main 10, AV1 Main 8- and 10-bit) it asks vkGetPhysicalDeviceVideoFormatPropertiesKHR in six usage combinations — the three the image pools really create with, plus DPB|DST without sampling, SAMPLED alone and DST alone, which are what localise a refusal to a half. Each answer is printed as the driver gave it: format, usage and create flags named AND in hex with unrecognised bits called out, image type, tiling. A failed query prints its VkResult rather than vanishing into an empty list. It goes through pf-vkdecode's own query rather than a copy of it, which meant splitting query_formats into a physical-device form — the call never needed the VkDevice the old signature demanded. VideoFormat gains imageType and imageTiling to carry the whole record; VUID-VkImageCreateInfo-pNext-06811 compares both for equality, so they were being assumed rather than read. And because a driver that under-reports usage would be indistinguishable from one that genuinely lacks it, the probe asks a second, independent question — vkGetPhysicalDeviceImageFormatProperties2 over the same profile list — and prints it only where the two disagree. A disagreement is the finding. No behaviour change to any decode path: derivation reads the same fields it did. --- Cargo.lock | 1 + clients/session/src/main.rs | 56 ++++ crates/pf-presenter/Cargo.toml | 6 + crates/pf-presenter/src/vk/mod.rs | 5 + crates/pf-presenter/src/vk/setup.rs | 21 ++ crates/pf-vkdecode/src/caps.rs | 82 +++++- crates/pf-vkdecode/src/caps_av1.rs | 3 + crates/pf-vkdecode/src/caps_h265.rs | 3 + crates/pf-vkdecode/src/images.rs | 1 + crates/pf-vkdecode/src/lib.rs | 1 + crates/pf-vkdecode/src/probe.rs | 418 ++++++++++++++++++++++++++++ 11 files changed, 585 insertions(+), 12 deletions(-) create mode 100644 crates/pf-vkdecode/src/probe.rs diff --git a/Cargo.lock b/Cargo.lock index 458d5a25..8401cb07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3196,6 +3196,7 @@ dependencies = [ "ash", "async-channel", "pf-client-core", + "pf-vkdecode", "punktfunk-core", "sdl3", "tracing", diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 88a8d46a..d84f031a 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -510,6 +510,61 @@ mod session_main { ); } + /// The driver's own answers about video images, printed with nothing in front of + /// them (`--probe-decode`). + /// + /// Passing the five conjuncts above only says Vulkan Video EXISTS on a device; this + /// says whether the zero-copy pipeline can be BUILT on it — a different question + /// with, on at least one shipping driver, a different answer. Verbatim on purpose: + /// the Intel Arc refusal was twice diagnosed from punktfunk's own error text and + /// twice the diagnosis was wrong, and what broke it open both times was reading what + /// the driver actually said. + fn print_video_formats(a: &pf_presenter::vk::AdapterDecode) { + use pf_presenter::vk::probe::{describe_create_flags, describe_usage}; + for p in &a.formats { + println!(" {} (wants {:?}):", p.profile, p.wanted); + for u in &p.usages { + let answer = match &u.formats { + Err(e) => format!("query failed: {e:?}"), + Ok(entries) if entries.is_empty() => "no formats offered".to_string(), + Ok(entries) => entries + .iter() + .map(|f| { + format!( + "{:?} usage={} create={} {:?} {:?}", + f.format, + describe_usage(f.image_usage), + describe_create_flags(f.image_create_flags), + f.image_type, + f.image_tiling, + ) + }) + .collect::>() + .join("; "), + }; + println!(" {:<24} {answer}", u.label); + // The independent second opinion, printed only where it DISAGREES with + // the format query. Agreement is the normal case and would be noise; a + // disagreement means one of the driver's two paths is wrong, which is + // the whole reason a second question gets asked at all. + let listed = u + .wanted_entry(p.wanted) + .is_some_and(|f| f.image_usage.contains(u.usage)); + if listed != u.image_format_support.is_ok() { + let second = match &u.image_format_support { + Ok(()) => "says creatable".to_string(), + Err(e) => format!("says {e:?}"), + }; + println!( + " {:<24} ^ DISAGREES: \ + vkGetPhysicalDeviceImageFormatProperties2 {second}", + "" + ); + } + } + } + } + pub fn run() -> u8 { // Logs to STDERR — stdout is the machine interface (ready/stats/error lines). tracing_subscriber::fmt() @@ -628,6 +683,7 @@ mod session_main { } else { println!(" extensions: {}", a.codec_exts.join(", ")); } + print_video_formats(a); } if adapters.len() > 1 { // The single most common misreading of this output: seeing a diff --git a/crates/pf-presenter/Cargo.toml b/crates/pf-presenter/Cargo.toml index 5c4fa827..40c789e3 100644 --- a/crates/pf-presenter/Cargo.toml +++ b/crates/pf-presenter/Cargo.toml @@ -17,6 +17,12 @@ repository.workspace = true # C++ in — fatal on Windows ARM64, where Granite has no SIMD path. pf-client-core = { path = "../pf-client-core", default-features = false } punktfunk-core = { path = "../punktfunk-core", features = ["quic"] } +# `--probe-decode` reports the driver's own video-format answers through pf-vkdecode's +# query rather than a second copy of it — a probe that keeps its own copy is a probe +# that eventually disagrees with the code it exists to explain (VIDEO_BASE's doc says +# the same thing about the extension list). Already in the tree via pf-client-core; +# named here because setup.rs calls it directly. +pf-vkdecode = { path = "../pf-vkdecode" } # `loaded` dlopens libvulkan at runtime (no link-time dependency — GPU-less boxes still # start and fail into a clean error; on Windows vulkan-1.dll is a GPU-driver component). diff --git a/crates/pf-presenter/src/vk/mod.rs b/crates/pf-presenter/src/vk/mod.rs index 998d506a..814cb780 100644 --- a/crates/pf-presenter/src/vk/mod.rs +++ b/crates/pf-presenter/src/vk/mod.rs @@ -43,6 +43,11 @@ mod setup; pub use setup::{list_adapters, probe_decode, AdapterDecode, PresentPref}; +/// The video-format probe behind [`AdapterDecode::formats`], re-exported so a caller +/// that prints the report does not need its own `pf-vkdecode` dependency (and cannot +/// end up printing a DIFFERENT crate version's idea of the flag names). +pub use pf_vkdecode::probe; + /// One presenter iteration's video input. pub enum FrameInput<'a> { /// No new frame — re-composite the retained video image (expose/resize). diff --git a/crates/pf-presenter/src/vk/setup.rs b/crates/pf-presenter/src/vk/setup.rs index 64f59d7a..9930ceaf 100644 --- a/crates/pf-presenter/src/vk/setup.rs +++ b/crates/pf-presenter/src/vk/setup.rs @@ -85,6 +85,16 @@ pub struct AdapterDecode { pub codec_exts: Vec, /// [`video_decode_gate`] over the fields above. pub usable: bool, + /// What the driver answers about video image formats, verbatim — one row per + /// (profile, usage) question ([`pf_vkdecode::probe`]). + /// + /// This is the half of the report that says why a device which passes every gate + /// above still cannot host the decoder. The five conjuncts answer "is Vulkan Video + /// here at all"; this answers "can the pipeline actually use it", which on at least + /// one shipping driver (Intel Arc, Windows) is a different question with a different + /// answer. Empty when the gate already failed — there is nothing to ask a device + /// with no video queue. + pub formats: Vec, } /// `VK_EXT_present_mode_fifo_latest_ready`, hand-declared: it postdates the Vulkan headers @@ -845,6 +855,16 @@ pub fn probe_decode() -> Result> { base_missing.is_empty(), !codec_exts.is_empty(), ); + // Only where the gate passed: the format queries need `VK_KHR_video_queue`'s + // entry points, and asking a device that does not expose them produces a null + // dispatch, not an answer. + let formats = if usable { + // SAFETY: `instance` is the live instance created above and `pdev` one of + // the physical devices it enumerated; the probe only reads. + unsafe { pf_vkdecode::probe::probe_video_formats(&entry, &instance, pdev) } + } else { + Vec::new() + }; out.push(( rank, AdapterDecode { @@ -858,6 +878,7 @@ pub fn probe_decode() -> Result> { base_missing, codec_exts, usable, + formats, }, )); } diff --git a/crates/pf-vkdecode/src/caps.rs b/crates/pf-vkdecode/src/caps.rs index 722b1772..e37963c0 100644 --- a/crates/pf-vkdecode/src/caps.rs +++ b/crates/pf-vkdecode/src/caps.rs @@ -86,7 +86,7 @@ pub const COINCIDE_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw( /// plus the driver's advertised usage/create-flag envelope for it — creation must /// stay INSIDE that envelope (finding of the adversarial round: the flags used to /// be assumed, not honoured). -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct VideoFormat { pub format: vk::Format, /// `imageUsageFlags` the driver supports for this format under the queried @@ -94,7 +94,38 @@ pub struct VideoFormat { pub image_usage: vk::ImageUsageFlags, /// `imageCreateFlags` the driver allows — per-plane views require /// `MUTABLE_FORMAT` to appear here. + /// + /// ⚠ This field is also the ONLY gate on `VK_IMAGE_CREATE_EXTENDED_USAGE_BIT`, + /// which is the spec's one escape hatch from `image_usage`: `supportedVideoFormat` + /// (VUID-VkImageCreateInfo-pNext-06811) admits a usage bit outside `image_usage` + /// only when `VkImageCreateInfo::flags` includes `EXTENDED_USAGE`, and admits that + /// flag only when it is "also set in `VkVideoFormatPropertiesKHR::imageCreateFlags`" + /// (or is `VIDEO_PROFILE_INDEPENDENT`, which needs `VK_KHR_video_maintenance1`). + /// So an EMPTY value here closes the escape hatch as well as the door — measured on + /// Intel Arc, where it is empty for every profile ([`crate::probe`] docs). pub image_create_flags: vk::ImageCreateFlags, + /// `imageType` — the image type this format may be created with. Part of the + /// `supportedVideoFormat` match (VUID-06811 compares it for EQUALITY), so it is + /// recorded rather than assumed; every fleet driver reports `TYPE_2D`, which is + /// what [`crate::images`] creates. + pub image_type: vk::ImageType, + /// `imageTiling` — likewise compared for equality by VUID-06811; every fleet + /// driver reports `OPTIMAL`. + pub image_tiling: vk::ImageTiling, +} + +impl Default for VideoFormat { + /// The shape the pools create with (`TYPE_2D` + `OPTIMAL`), so a fixture that + /// names only the interesting fields still describes a creatable image. + fn default() -> Self { + Self { + format: vk::Format::UNDEFINED, + image_usage: vk::ImageUsageFlags::empty(), + image_create_flags: vk::ImageCreateFlags::empty(), + image_type: vk::ImageType::TYPE_2D, + image_tiling: vk::ImageTiling::OPTIMAL, + } + } } /// Everything the thin query copies out of the driver, hand-buildable for tests. @@ -677,6 +708,36 @@ pub(crate) unsafe fn query_formats( dev: &DecodeDevice, decode_profile: DecodeProfile, usage: vk::ImageUsageFlags, +) -> Result, vk::Result> { + // SAFETY: the caller's DeviceHandles contract makes these two live, which is + // exactly what the physical-device form needs. + unsafe { + query_formats_on( + dev.video_queue_instance(), + dev.physical_device(), + decode_profile, + usage, + ) + } +} + +/// [`query_formats`] against a bare physical device — no `VkDevice` in sight. +/// +/// Split out so [`crate::probe`] enumerates through the SAME code the session's caps +/// query runs, rather than a second copy that would drift (the probe's whole value is +/// that its answer is the one derivation will see). `vkGetPhysicalDeviceVideoFormat- +/// PropertiesKHR` is an instance-level command over a physical device, so nothing here +/// ever needed the logical device the old signature demanded. +/// +/// # Safety +/// +/// `video_queue_instance` must be loaded against a live `VkInstance`, and +/// `physical_device` must be one of that instance's physical devices. +pub(crate) unsafe fn query_formats_on( + video_queue_instance: &ash::khr::video_queue::Instance, + physical_device: vk::PhysicalDevice, + decode_profile: DecodeProfile, + usage: vk::ImageUsageFlags, ) -> Result, vk::Result> { let mut chain = decode_profile.chain(); let profile = chain.wire(); @@ -686,21 +747,13 @@ pub(crate) unsafe fn query_formats( .image_usage(usage) .push_next(&mut profile_list); - let fp = dev - .video_queue_instance() + let fp = video_queue_instance .fp() .get_physical_device_video_format_properties_khr; let mut count = 0u32; // SAFETY: live physical device; `info` roots a wired chain outliving the call; // null properties pointer is the spec's count-query form. - let r = unsafe { - fp( - dev.physical_device(), - &info, - &mut count, - std::ptr::null_mut(), - ) - }; + let r = unsafe { fp(physical_device, &info, &mut count, std::ptr::null_mut()) }; match r { vk::Result::SUCCESS => {} // "This usage/profile combination has no formats" — an arrangement gap, @@ -711,7 +764,7 @@ pub(crate) unsafe fn query_formats( } let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize]; // SAFETY: as above, with a properties array of exactly the driver-reported count. - let r = unsafe { fp(dev.physical_device(), &info, &mut count, props.as_mut_ptr()) }; + let r = unsafe { fp(physical_device, &info, &mut count, props.as_mut_ptr()) }; if r != vk::Result::SUCCESS && r != vk::Result::INCOMPLETE { return Err(r); } @@ -722,6 +775,8 @@ pub(crate) unsafe fn query_formats( format: p.format, image_usage: p.image_usage_flags, image_create_flags: p.image_create_flags, + image_type: p.image_type, + image_tiling: p.image_tiling, }) .collect()) } @@ -738,6 +793,7 @@ mod tests { image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT | vk::ImageCreateFlags::ALIAS | vk::ImageCreateFlags::EXTENDED_USAGE, + ..Default::default() } } @@ -783,6 +839,7 @@ mod tests { format: NV12, image_usage: DPB_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }], output_formats: vec![entry(NV12, OUTPUT_USAGE)], coincide_formats: vec![], @@ -927,6 +984,7 @@ mod tests { format: NV12, image_usage: COINCIDE_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }]; assert_eq!( derive_caps(&raw).unwrap_err(), diff --git a/crates/pf-vkdecode/src/caps_av1.rs b/crates/pf-vkdecode/src/caps_av1.rs index 509ae1b9..26073201 100644 --- a/crates/pf-vkdecode/src/caps_av1.rs +++ b/crates/pf-vkdecode/src/caps_av1.rs @@ -354,6 +354,7 @@ mod tests { format, image_usage: usage, image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT, + ..Default::default() } } @@ -638,6 +639,7 @@ mod tests { format: P010, image_usage: DPB_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }], output_formats: vec![entry(NV12, OUTPUT_USAGE)], ..coincide_device(vec![]) @@ -683,6 +685,7 @@ mod tests { format: NV12, image_usage: COINCIDE_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }]); assert_eq!( derive_caps_av1(&raw, NV12).unwrap_err(), diff --git a/crates/pf-vkdecode/src/caps_h265.rs b/crates/pf-vkdecode/src/caps_h265.rs index a25c92ff..24c8fcf0 100644 --- a/crates/pf-vkdecode/src/caps_h265.rs +++ b/crates/pf-vkdecode/src/caps_h265.rs @@ -383,6 +383,7 @@ mod tests { format, image_usage: usage, image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT, + ..Default::default() } } @@ -722,6 +723,7 @@ mod tests { format: P010, image_usage: DPB_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }], output_formats: vec![entry(NV12, OUTPUT_USAGE)], ..coincide_device(vec![]) @@ -767,6 +769,7 @@ mod tests { format: P010, image_usage: COINCIDE_USAGE, image_create_flags: vk::ImageCreateFlags::empty(), + ..Default::default() }]); assert_eq!( derive_caps_h265(&raw, P010).unwrap_err(), diff --git a/crates/pf-vkdecode/src/images.rs b/crates/pf-vkdecode/src/images.rs index 900af398..139759b8 100644 --- a/crates/pf-vkdecode/src/images.rs +++ b/crates/pf-vkdecode/src/images.rs @@ -497,6 +497,7 @@ mod tests { format: NV12, image_usage: usage, image_create_flags: vk::ImageCreateFlags::MUTABLE_FORMAT, + ..Default::default() }; let raw = RawH264Caps { capability_flags: if layered { diff --git a/crates/pf-vkdecode/src/lib.rs b/crates/pf-vkdecode/src/lib.rs index ccc1fd95..d46407ae 100644 --- a/crates/pf-vkdecode/src/lib.rs +++ b/crates/pf-vkdecode/src/lib.rs @@ -133,6 +133,7 @@ pub mod params_h265; pub mod pic; pub mod pic_av1; pub mod pic_h265; +pub mod probe; pub mod recovery; pub mod ring; pub mod session; diff --git a/crates/pf-vkdecode/src/probe.rs b/crates/pf-vkdecode/src/probe.rs new file mode 100644 index 00000000..aa7f2455 --- /dev/null +++ b/crates/pf-vkdecode/src/probe.rs @@ -0,0 +1,418 @@ +//! What the driver ACTUALLY answers about video image formats, verbatim — the +//! physical-device-only probe behind `punktfunk-session --probe-decode`. +//! +//! # Why this exists +//! +//! Twice in a row an Intel Arc refusal was diagnosed from punktfunk's OWN error text +//! and twice the conclusion ("Intel driver bug") was wrong — the bug was ours, in the +//! `pNext` order of the capability query. What broke it open both times was logging +//! the driver's answer with no interpretation in front of it. This module makes that +//! the DEFAULT rather than a debugging afterthought: for every decode profile the +//! client can negotiate, it asks the driver the same question the session's caps query +//! asks, in every usage combination the image pools would create with, and records +//! what came back — including the failures, spelled as the `VkResult` the driver +//! returned. +//! +//! It shares [`crate::caps::query_formats_on`] with the real caps path on purpose. A +//! probe with its own copy of the query is a probe that eventually disagrees with the +//! code it is meant to explain, which is worse than no probe at all. +//! +//! # What it measured +//! +//! Intel Arc (Windows driver 101.8861, 2026-08): every one of the 26 decode profiles +//! reports the SAME envelope for its NV12/P010 picture format — +//! `TRANSFER_SRC | VIDEO_DECODE_DST | VIDEO_DECODE_DPB`, and `imageCreateFlags` EMPTY — +//! with `DPB_AND_OUTPUT_COINCIDE` as the only decode mode. No `SAMPLED`, so a shader +//! cannot read the decoded picture; and no `MUTABLE_FORMAT`/`EXTENDED_USAGE`, which +//! closes the spec's only escape hatch (see [`crate::caps::VideoFormat:: +//! image_create_flags`]). `TRANSFER_SRC` is the sole way out of the image — i.e. a +//! copy. NVIDIA (596.41) answers the same queries with +//! `TRANSFER_SRC|TRANSFER_DST|SAMPLED|DECODE_DST|DECODE_DPB|ENCODE_SRC|ENCODE_DPB` and +//! `MUTABLE_FORMAT|EXTENDED_USAGE`, which is what makes the zero-copy path work there. +//! +//! The [`UsageProbe::image_format_support`] cross-check exists because of the ONE +//! inconsistency in the above: asked for a usage it does not support, the Intel driver +//! returns `VK_SUCCESS` with an entry that lacks the requested bit, where the spec says +//! the returned `imageUsageFlags` "will contain at least the same set of image usage +//! flags" (and `VK_ERROR_IMAGE_USAGE_NOT_SUPPORTED_KHR` is the documented refusal). So +//! the probe asks a SECOND, independent question — `vkGetPhysicalDeviceImageFormat- +//! Properties2` with the same profile list chained — and prints both answers. Where the +//! two disagree, the disagreement itself is the finding. + +use ash::vk; +use ash::vk::native as hh; + +use crate::caps::query_formats_on; +use crate::caps::DecodeProfile; +use crate::caps::VideoFormat; +use crate::caps::COINCIDE_USAGE; +use crate::caps::DPB_USAGE; +use crate::caps::NV12; +use crate::caps::OUTPUT_USAGE; +use crate::caps::P010; +use crate::caps_av1::Av1ProfileKey; +use crate::caps_h265::H265ProfileKey; + +/// The usage combinations the probe asks about, widest question first. +/// +/// The first three are the REAL ones — exactly what [`crate::images`] creates with, so +/// their answers are the ones derivation acts on. The rest exist to localise a refusal: +/// when `DPB|DST|SAMPLED` fails, `DPB|DST` says whether the decode roles alone are fine +/// (i.e. the gap is sampling) and `SAMPLED` alone says whether the format is sampleable +/// under this profile at all. Without them, a single failed query leaves "which half is +/// missing?" to inference — which is how this device got misdiagnosed twice. +const USAGE_MATRIX: [(&str, vk::ImageUsageFlags); 6] = [ + ("coincide DPB|DST|SAMPLED", COINCIDE_USAGE), + ("distinct DPB", DPB_USAGE), + ("distinct DST|SAMPLED", OUTPUT_USAGE), + ("DPB|DST (no SAMPLED)", DECODE_ONLY_USAGE), + ("SAMPLED alone", vk::ImageUsageFlags::SAMPLED), + ("DST alone", vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR), +]; + +/// Decode roles with sampling deliberately withheld — the discriminator between "this +/// device cannot decode this profile" and "it can decode it but not let anyone read it". +const DECODE_ONLY_USAGE: vk::ImageUsageFlags = vk::ImageUsageFlags::from_raw( + vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR.as_raw() + | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR.as_raw(), +); + +/// One decode profile's worth of driver answers. +#[derive(Debug, Clone)] +pub struct ProfileProbe { + /// The profile as a human would name it ("H.265 Main 4:2:0 8-bit"). + pub profile: &'static str, + /// The picture format this profile decodes to — the entry the probe looks for. + pub wanted: vk::Format, + /// One row per [`USAGE_MATRIX`] entry, in that order. + pub usages: Vec, +} + +/// The driver's answer for ONE (profile, usage) question. +#[derive(Debug, Clone)] +pub struct UsageProbe { + pub label: &'static str, + pub usage: vk::ImageUsageFlags, + /// `vkGetPhysicalDeviceVideoFormatPropertiesKHR`: every entry it returned, or the + /// `VkResult` it failed with. An EMPTY vector is itself an answer — the two + /// "no formats for this combination" results + /// (`ERROR_FORMAT_NOT_SUPPORTED`/`ERROR_IMAGE_USAGE_NOT_SUPPORTED_KHR`) are mapped + /// to it by the shared query, exactly as derivation sees them. + pub formats: Result, vk::Result>, + /// `vkGetPhysicalDeviceImageFormatProperties2` for the SAME profile list, format + /// and usage — the independent second opinion (module docs). `Ok(())` means the + /// driver says an image of that shape is creatable. + pub image_format_support: Result<(), vk::Result>, +} + +impl UsageProbe { + /// The entry for the profile's picture format, if the driver returned one. + pub fn wanted_entry(&self, wanted: vk::Format) -> Option { + self.formats + .as_ref() + .ok()? + .iter() + .copied() + .find(|f| f.format == wanted) + } +} + +/// The profiles worth probing: one per codec rung the client can negotiate, plus the +/// 10-bit legs, because an HDR stream picks a DIFFERENT Vulkan profile from an SDR one +/// and a device may well host one and not the other. +/// +/// Deliberately NOT every profile the driver supports — this answers "can punktfunk +/// decode here", and a list padded with profiles no rung ever requests makes the row +/// that matters harder to find. `vulkaninfo --show-video-props` is the tool for the +/// exhaustive sweep. +fn probed_profiles() -> Vec<(&'static str, DecodeProfile, vk::Format)> { + // Every key is built through the SAME constructor the decoders negotiate with + // (`from_negotiated`), so a profile the client could never request cannot appear + // here — and a combination those constructors refuse simply drops out of the list + // instead of being hand-rolled into existence for the probe's benefit. + // 4:2:0 is chroma_format_idc 1 in both codecs' vocabulary; H.265 states depth as + // `bit_depth_luma_minus8`, AV1 in whole bits. + let mut out: Vec<(&'static str, DecodeProfile, vk::Format)> = vec![( + "H.264 High 4:2:0 8-bit", + DecodeProfile::H264(hh::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH), + NV12, + )]; + if let Ok(key) = H265ProfileKey::from_negotiated(1, 0) { + out.push(("H.265 Main 4:2:0 8-bit", DecodeProfile::H265(key), NV12)); + } + if let Ok(key) = H265ProfileKey::from_negotiated(1, 2) { + out.push(("H.265 Main 10 4:2:0 10-bit", DecodeProfile::H265(key), P010)); + } + // AV1 without film grain: `filmGrainSupport` is part of the Vulkan PROFILE, and + // grain-less is what a punktfunk host encodes. + if let Ok(key) = Av1ProfileKey::from_negotiated(1, 8, false) { + out.push(("AV1 Main 4:2:0 8-bit", DecodeProfile::Av1(key), NV12)); + } + if let Ok(key) = Av1ProfileKey::from_negotiated(1, 10, false) { + out.push(("AV1 Main 4:2:0 10-bit", DecodeProfile::Av1(key), P010)); + } + out +} + +/// Ask one physical device every question in the matrix, for every probed profile. +/// +/// Never fails as a whole: a driver that refuses a profile outright is a ROW in the +/// output, not an error return — the point is to come back with the full picture even +/// when most of it is refusals. +/// +/// # Safety +/// +/// `instance` must be a live `VkInstance` created through `entry`, and +/// `physical_device` one of its physical devices. Nothing here creates or destroys +/// anything; every call is a physical-device query. +pub unsafe fn probe_video_formats( + entry: &ash::Entry, + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, +) -> Vec { + let video_queue_instance = ash::khr::video_queue::Instance::new(entry, instance); + probed_profiles() + .into_iter() + .map(|(profile, decode_profile, wanted)| { + let usages = USAGE_MATRIX + .iter() + .map(|(label, usage)| { + // SAFETY: caller contract — live instance the video_queue table was + // loaded from, and one of its physical devices. + let formats = unsafe { + query_formats_on( + &video_queue_instance, + physical_device, + decode_profile, + *usage, + ) + }; + // SAFETY: as above. + let image_format_support = unsafe { + image_format_supported( + instance, + physical_device, + decode_profile, + wanted, + *usage, + ) + }; + UsageProbe { + label, + usage: *usage, + formats, + image_format_support, + } + }) + .collect(); + ProfileProbe { + profile, + wanted, + usages, + } + }) + .collect() +} + +/// The second opinion: can an image of (`format`, `usage`) exist for this video profile, +/// according to `vkGetPhysicalDeviceImageFormatProperties2`? +/// +/// This is the same question `vkCreateImage` will be validated against +/// (VUID-VkImageCreateInfo-pNext-06811 routes through the video format properties, but +/// the general image-format query is what reports whether the combination is creatable +/// at all), asked through a DIFFERENT entry point. Where it disagrees with the video +/// format properties, one of the two driver paths is wrong — and knowing which is the +/// difference between a bug report to Intel and a fix in this repository. +/// +/// # Safety +/// +/// As [`probe_video_formats`]. +unsafe fn image_format_supported( + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + decode_profile: DecodeProfile, + format: vk::Format, + usage: vk::ImageUsageFlags, +) -> Result<(), vk::Result> { + let mut chain = decode_profile.chain(); + let profile = chain.wire(); + let mut profile_list = + vk::VideoProfileListInfoKHR::default().profiles(std::slice::from_ref(profile)); + let info = vk::PhysicalDeviceImageFormatInfo2::default() + .format(format) + .ty(vk::ImageType::TYPE_2D) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(usage) + .push_next(&mut profile_list); + let mut props = vk::ImageFormatProperties2::default(); + // SAFETY: caller contract (live instance + one of its physical devices); `info` + // roots a wired chain of locals that outlive the call, and `props` is a local the + // driver fills. + unsafe { + instance.get_physical_device_image_format_properties2(physical_device, &info, &mut props) + } +} + +/// `usage` as `NAME|NAME (0xHEX)`, with any bit this build cannot name kept VISIBLE. +/// +/// The raw value is always printed beside the words for the same reason the codec-op +/// line prints its mask: a reader must be able to check the names against the number, +/// and a bit the tool has no word for must not silently vanish from a mask it reports. +pub fn describe_usage(usage: vk::ImageUsageFlags) -> String { + const BITS: [(vk::ImageUsageFlags, &str); 9] = [ + (vk::ImageUsageFlags::TRANSFER_SRC, "TRANSFER_SRC"), + (vk::ImageUsageFlags::TRANSFER_DST, "TRANSFER_DST"), + (vk::ImageUsageFlags::SAMPLED, "SAMPLED"), + (vk::ImageUsageFlags::STORAGE, "STORAGE"), + (vk::ImageUsageFlags::COLOR_ATTACHMENT, "COLOR_ATTACHMENT"), + (vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR, "DECODE_DST"), + (vk::ImageUsageFlags::VIDEO_DECODE_SRC_KHR, "DECODE_SRC"), + (vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR, "DECODE_DPB"), + (vk::ImageUsageFlags::INPUT_ATTACHMENT, "INPUT_ATTACHMENT"), + ]; + describe_mask(usage.as_raw(), &BITS.map(|(f, n)| (f.as_raw(), n))) +} + +/// `imageCreateFlags` as `NAME|NAME (0xHEX)`, same accounting rule as +/// [`describe_usage`]. +pub fn describe_create_flags(flags: vk::ImageCreateFlags) -> String { + const BITS: [(vk::ImageCreateFlags, &str); 5] = [ + (vk::ImageCreateFlags::MUTABLE_FORMAT, "MUTABLE_FORMAT"), + (vk::ImageCreateFlags::EXTENDED_USAGE, "EXTENDED_USAGE"), + (vk::ImageCreateFlags::ALIAS, "ALIAS"), + (vk::ImageCreateFlags::DISJOINT, "DISJOINT"), + (vk::ImageCreateFlags::PROTECTED, "PROTECTED"), + ]; + describe_mask(flags.as_raw(), &BITS.map(|(f, n)| (f.as_raw(), n))) +} + +/// The shared naming rule: named bits joined by `|`, then the raw value, then any +/// leftover bits called out as unrecognised rather than dropped. +fn describe_mask(raw: u32, bits: &[(u32, &str)]) -> String { + if raw == 0 { + return "(none) (0x0)".to_string(); + } + let mut names: Vec<&str> = bits + .iter() + .filter(|(bit, _)| raw & bit != 0) + .map(|(_, name)| *name) + .collect(); + let named: u32 = bits.iter().map(|(bit, _)| bit).sum(); + let leftover = raw & !named; + let extra; + if leftover != 0 { + extra = format!("unrecognised 0x{leftover:X}"); + names.push(&extra); + } + format!("{} (0x{raw:X})", names.join("|")) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The matrix must contain the three combinations the pools REALLY create with — + /// a probe that answers about usages nobody creates explains nothing. + #[test] + fn the_matrix_covers_every_usage_the_pools_create_with() { + for real in [COINCIDE_USAGE, DPB_USAGE, OUTPUT_USAGE] { + assert!( + USAGE_MATRIX.iter().any(|(_, u)| *u == real), + "{real:?} is created by the image pools but never probed" + ); + } + // And the two discriminators that localise a refusal. + assert!(USAGE_MATRIX.iter().any(|(_, u)| *u == DECODE_ONLY_USAGE)); + assert!(USAGE_MATRIX + .iter() + .any(|(_, u)| *u == vk::ImageUsageFlags::SAMPLED)); + } + + /// Every profile the client can negotiate gets a row, each with the picture format + /// its caps derivation will look for — a probe that reported a 10-bit profile + /// against NV12 would "find" nothing and read as a device gap. + #[test] + fn every_probed_profile_names_the_format_derivation_wants() { + let profiles = probed_profiles(); + assert!( + profiles.len() >= 5, + "expected H.264 + H.265 8/10-bit + AV1 8/10-bit, got {}", + profiles.len() + ); + for (name, _, wanted) in &profiles { + assert!( + crate::caps::OUTPUT_FORMATS.contains(wanted), + "{name} wants {wanted:?}, which is outside this crate's output vocabulary" + ); + } + assert!(profiles.iter().any(|(_, _, w)| *w == P010), "no 10-bit leg"); + } + + /// The mask printers must never drop a bit: names AND the raw value AND anything + /// unnamed. This is the accounting rule the codec-op line already follows, and the + /// reason it exists is that a silently-dropped bit reads as a capability the device + /// does not have (or worse, hides one it does). + #[test] + fn mask_descriptions_account_for_every_bit_they_print() { + assert_eq!( + describe_usage(COINCIDE_USAGE), + "SAMPLED|DECODE_DST|DECODE_DPB (0x1404)" + ); + assert_eq!(describe_usage(vk::ImageUsageFlags::empty()), "(none) (0x0)"); + // The Intel Arc envelope, verbatim — the string a field report will contain. + let intel = vk::ImageUsageFlags::TRANSFER_SRC + | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR + | vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR; + assert_eq!( + describe_usage(intel), + "TRANSFER_SRC|DECODE_DST|DECODE_DPB (0x1401)" + ); + // A bit with no name still shows up. + let unknown = vk::ImageUsageFlags::from_raw(0x8000_0000); + assert_eq!( + describe_usage(unknown), + "unrecognised 0x80000000 (0x80000000)" + ); + assert_eq!( + describe_create_flags( + vk::ImageCreateFlags::MUTABLE_FORMAT | vk::ImageCreateFlags::EXTENDED_USAGE + ), + "MUTABLE_FORMAT|EXTENDED_USAGE (0x108)" + ); + assert_eq!( + describe_create_flags(vk::ImageCreateFlags::empty()), + "(none) (0x0)" + ); + } + + /// `wanted_entry` picks by FORMAT, so a driver that returns several entries cannot + /// hide the one derivation will act on behind a different format. + #[test] + fn the_wanted_entry_is_found_by_format_among_others() { + let probe = UsageProbe { + label: "x", + usage: COINCIDE_USAGE, + formats: Ok(vec![ + VideoFormat { + format: P010, + image_usage: COINCIDE_USAGE, + ..Default::default() + }, + VideoFormat { + format: NV12, + image_usage: DPB_USAGE, + ..Default::default() + }, + ]), + image_format_support: Ok(()), + }; + assert_eq!(probe.wanted_entry(NV12).unwrap().image_usage, DPB_USAGE); + assert!(probe.wanted_entry(crate::caps::YUV444_8).is_none()); + // A failed query has no entry at all — distinct from "returned nothing". + let failed = UsageProbe { + formats: Err(vk::Result::ERROR_IMAGE_USAGE_NOT_SUPPORTED_KHR), + ..probe + }; + assert!(failed.wanted_entry(NV12).is_none()); + } +}