diff --git a/crates/pf-vkdecode/tests/common/mod.rs b/crates/pf-vkdecode/tests/common/mod.rs new file mode 100644 index 00000000..ca76c8bf --- /dev/null +++ b/crates/pf-vkdecode/tests/common/mod.rs @@ -0,0 +1,532 @@ +//! Shared Vulkan Video bring-up for the `#[ignore]`d GPU legs. +//! +//! `tests/gpu_smoke.rs` and `tests/gpu_parity.rs` each drive TWO codecs, and the +//! path from "a Vulkan loader exists" to "a [`DeviceHandles`] a decoder can be +//! constructed on" is the same ~150 unsafe lines every time: loader → instance → +//! pick a physical device whose queue families carry the codec's decode ops → +//! logical device with the decode extensions plus `timelineSemaphore` and +//! `synchronization2`. Four copies of that would be four places for a +//! fleet-only failure to hide, so it lives here once, parameterised by the one +//! thing that genuinely differs between the callers ([`Graphics`]: the parity +//! legs read back on a graphics queue and so REQUIRE one, while the smoke legs +//! accept a decode-only device and fall back to the decode family — which also +//! decides whether pool images end up EXCLUSIVE or CONCURRENT, so it is not +//! cosmetic). [`Request::report_families`] exists so a caller CAN suppress the +//! per-family table, but all four legs currently ask for it: it is the first +//! thing a fleet failure report needs, and it is a physical-device property +//! query, never a recorded RESULT_STATUS query, so it cannot trip the RADV VCN +//! hang. +//! +//! Cargo does not treat `tests/common/mod.rs` as a test target of its own (it +//! auto-discovers `tests/*.rs` and `tests/*/main.rs` only), so this file is +//! compiled purely as a `mod common;` of each test binary — and therefore under +//! each one's `#![deny(clippy::undocumented_unsafe_blocks)]`. +//! +//! Environment knobs, honoured exactly as they were before this module existed: +//! - `PF_VKD_SMOKE_VENDOR` (hex `0x1002`/`0x10de`, or decimal): pin a PCI vendor +//! on a multi-GPU box, so a run is attributable to one driver instead of +//! whichever device enumerated first. +//! - RADV additionally needs `RADV_PERFTEST=video_decode` in the environment; +//! without it no device advertises the decode extensions and [`bring_up`] +//! panics with "no physical device with VK_KHR_video_decode_*", which is the +//! correct report rather than a confusing later failure. + +// Each of the two test binaries drives a different subset of this module (the +// smoke legs never touch `Setup::pd`, the parity legs never pass +// `Graphics::DecodeFamilyIsFine`), and a test binary gets no `pub` exemption +// from dead-code analysis. +#![allow(dead_code)] + +use std::io::Cursor; + +use ash::vk; +use ash::vk::Handle; +use pf_vkdecode::DecodeStatus; +use pf_vkdecode::DecodedVkFrame; +use pf_vkdecode::DeviceHandles; +use pf_vkdecode::VkDecodeError; + +/// The vendored H.264 vector both GPU legs decode: 250 AUs of real encoder +/// output, 320x240 — the same file pf-bitstream's WP-A tests plan, at the same +/// relative path. It is **two slice NALUs per picture** (500 slice NALs over 250 +/// AUs, with 4 IDRs), which is why the splitter's `first_mb_in_slice == 0` branch +/// is load-bearing here rather than decorative — and, per libavcodec's own DXVA +/// slice-control descriptors captured on hardware, why its slice-control buffer +/// is two records wide where the HEVC vector's is one. +pub const TEST_25FPS_H264: &[u8] = include_bytes!( + "../../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" +); + +/// The vendored H.265 twin: 250 AUs, 320x240 Main 8-bit 4:2:0, one IDR_N_LP then +/// 249 TRAIL pictures (verified by pf-bitstream's +/// `the_full_25fps_vector_plans_every_picture_and_every_pic_id_reaches_output`). +pub const TEST_25FPS_H265: &[u8] = include_bytes!( + "../../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" +); + +/// Test-only H.264 AU splitter, mirroring pf-bitstream's +/// (`#[cfg(test)]`-private there): a new AU starts at a non-VCL NALU following +/// slices, or at a slice whose `first_mb_in_slice` is 0 (the first bit of the byte +/// after the 1-byte NAL header) when the current AU already has slices. +pub fn split_h264_aus(stream: &[u8]) -> Vec<&[u8]> { + use cros_codecs::codec::h264::parser::Nalu; + use cros_codecs::codec::h264::parser::NaluType; + + let mut aus = Vec::new(); + let mut cursor = Cursor::new(stream); + let mut au_start = 0usize; + let mut au_has_slice = false; + + while let Ok(nalu) = Nalu::next(&mut cursor) { + let nalu_offset = cursor.position() as usize; + let start = nalu_offset - nalu.offset; + let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr); + let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0); + + if au_has_slice && (!is_slice || first_mb_zero) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus +} + +/// Test-only H.265 AU splitter, a verbatim copy of pf-bitstream's +/// (`#[cfg(test)]`-private in `h265.rs`, and the same one `pic_h265`'s and +/// `fault_detection`'s tests carry). +/// +/// The two differences from [`split_h264_aus`] are the whole point and are why +/// this is copied rather than re-derived: HEVC's NAL header is TWO bytes, so +/// `first_slice_segment_in_pic_flag` is the top bit of `stream[header_start + 2]` +/// (H.264 reads `+ 1`), and "is a slice" is the numeric range `nal_unit_type < 32` +/// rather than an enum pair. Getting either wrong silently merges or splits AUs, +/// which shows up as a frame-count mismatch a long way from its cause. +pub fn split_h265_aus(stream: &[u8]) -> Vec<&[u8]> { + use cros_codecs::codec::h265::parser::Nalu; + + let mut aus = Vec::new(); + let mut cursor = Cursor::new(stream); + let mut au_start = 0usize; + let mut au_has_slice = false; + + while let Ok(nalu) = Nalu::next(&mut cursor) { + let header_start = cursor.position() as usize; + let start = header_start - nalu.offset; + let is_slice = (nalu.header.type_ as u32) < 32; + let first_slice_flag = + is_slice && stream.get(header_start + 2).is_some_and(|b| b & 0x80 != 0); + + if au_has_slice && (!is_slice || first_slice_flag) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus +} + +/// The slice of a decoder's surface the GPU legs drive. +/// +/// `VkH264Decoder` and `VkH265Decoder` expose it method-for-method (the crate +/// docs say so deliberately) but share no trait — codec DISPATCH is the client +/// wiring's job, not this crate's. Binding it here lets each GPU leg run ONE body +/// against both codecs, which is the only way "the H.265 leg proves the same thing +/// the H.264 leg does" can be a fact instead of a claim about two hand-copied +/// functions. +pub trait TestDecoder { + fn decode(&mut self, au: &[u8]) -> Result, VkDecodeError>; + fn take_ready(&mut self) -> Option; + fn wait_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus; + fn release_frame( + &mut self, + frame: &DecodedVkFrame, + presenter_signaled: bool, + ) -> Result<(), VkDecodeError>; + fn flush(&mut self); + fn status_queries(&self) -> bool; + fn debug_snapshot(&self) -> String; +} + +/// Forwarding impl — one macro so the two decoders can never drift into being +/// driven differently by accident. +macro_rules! impl_test_decoder { + ($ty:ty) => { + impl TestDecoder for $ty { + fn decode(&mut self, au: &[u8]) -> Result, VkDecodeError> { + <$ty>::decode(self, au) + } + fn take_ready(&mut self) -> Option { + <$ty>::take_ready(self) + } + fn wait_status(&mut self, frame: &DecodedVkFrame) -> DecodeStatus { + <$ty>::wait_status(self, frame) + } + fn release_frame( + &mut self, + frame: &DecodedVkFrame, + presenter_signaled: bool, + ) -> Result<(), VkDecodeError> { + <$ty>::release_frame(self, frame, presenter_signaled) + } + fn flush(&mut self) { + <$ty>::flush(self); + } + fn status_queries(&self) -> bool { + <$ty>::status_queries(self) + } + fn debug_snapshot(&self) -> String { + <$ty>::debug_snapshot(self) + } + } + }; +} + +impl_test_decoder!(pf_vkdecode::VkH264Decoder); +impl_test_decoder!(pf_vkdecode::VkH265Decoder); + +/// Serializes the GPU legs within one test binary. Hold it for the whole leg. +/// +/// Cargo runs a binary's tests on PARALLEL threads by default. While each GPU test +/// file held exactly one test that never mattered; with one leg per codec it +/// matters twice over: +/// +/// - two decoders would contend for the same decode queue and double peak video +/// memory on a device that may not have it, turning an attribution run into a +/// race and any failure into something nobody can pin on a codec; +/// - the parity legs set `PF_VKD_TEST_READBACK` through `std::env::set_var`, which +/// is not thread-safe and would be racing a second leg's reads of it. +/// +/// Poisoning is deliberately ignored: if the first leg panics, the second must +/// still run and report its own codec's verdict rather than fail as a casualty. +pub fn gpu_lock() -> std::sync::MutexGuard<'static, ()> { + static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(()); + GPU.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Which codec a bring-up must find silicon (and an extension) for. +#[derive(Clone, Copy)] +pub struct Codec { + /// The video codec operation the chosen decode queue family must advertise. + /// Checked per FAMILY, not per device: a device can advertise the extension + /// while only one of its families carries the op. + pub op: vk::VideoCodecOperationFlagsKHR, + /// The codec's device extension — required on the physical device AND enabled + /// on the logical one, per [`DeviceHandles`]' contract (a decoder whose + /// extension was not enabled reaches `vkCreateVideoSessionKHR` on an + /// unenabled codec). + pub extension: &'static std::ffi::CStr, +} + +/// H.264 decode (`VkH264Decoder`). +pub const H264: Codec = Codec { + op: vk::VideoCodecOperationFlagsKHR::DECODE_H264, + extension: ash::khr::video_decode_h264::NAME, +}; + +/// H.265 decode (`VkH265Decoder`). +pub const H265: Codec = Codec { + op: vk::VideoCodecOperationFlagsKHR::DECODE_H265, + extension: ash::khr::video_decode_h265::NAME, +}; + +/// What a caller needs from the GRAPHICS queue family — the one behavioural +/// difference between the smoke and parity bring-ups, an explicit parameter so +/// it cannot drift back into being an accident of two copied loops. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Graphics { + /// A device with no graphics family is still usable: `graphics_qf` falls back + /// to the decode family. The smoke legs want this — they submit nothing + /// outside the decoder. The fallback is not cosmetic: `decode_qf == + /// graphics_qf` makes the picture pool's images EXCLUSIVE rather than + /// CONCURRENT (`DecodeDevice::sharing_families`), which is exactly the + /// arrangement a decode-only device has to run. + DecodeFamilyIsFine, + /// A device without a graphics family is SKIPPED, not defaulted. The parity + /// legs want this — their readback records `vkCmdCopyImageToBuffer` on the + /// graphics queue. + Required, +} + +/// One bring-up request. A struct rather than positional arguments because the +/// two fields below are precisely where the callers disagree, and a bare +/// `bring_up(H265, true, false)` at a call site is how that disagreement becomes +/// invisible again. +pub struct Request { + /// The codec whose decode ops and extension are required. + pub codec: Codec, + /// Whether a graphics queue family is required or merely preferred. + pub graphics: Graphics, + /// Print each candidate device's per-family `flags / video_ops / + /// query_result_status` table. It is the first thing a fleet failure report + /// needs: which families exist, which of them decode this codec, and whether + /// per-op status verdicts exist on this box at all (RADV: they do not, and + /// recording one hangs the VCN — the 2026-08 .25 lesson). + pub report_families: bool, +} + +/// A live instance + logical device a decoder can be constructed on. +/// +/// Torn down explicitly through [`Setup::destroy`] rather than `Drop`, so the +/// ordering against the decoder — which must be gone FIRST — stays visible in the +/// test body, exactly as it was when each test carried its own teardown. +pub struct Setup { + /// Kept because [`Setup::handles`] hands the loader's + /// `vkGetInstanceProcAddr` to the decoder, which resolves everything through + /// it. + pub entry: ash::Entry, + pub instance: ash::Instance, + pub pd: vk::PhysicalDevice, + pub device: ash::Device, + pub decode_qf: u32, + /// The graphics family, or `decode_qf` under + /// [`Graphics::DecodeFamilyIsFine`] when the device has none. + pub graphics_qf: u32, +} + +impl Setup { + /// The borrowed-handle bundle both decoders are constructed from. Valid only + /// while `self` is alive and un-destroyed ([`DeviceHandles`]' contract). + pub fn handles(&self) -> DeviceHandles { + DeviceHandles { + get_instance_proc_addr: self.entry.static_fn().get_instance_proc_addr as usize, + instance: self.instance.handle().as_raw() as usize, + physical_device: self.pd.as_raw() as usize, + device: self.device.handle().as_raw() as usize, + decode_qf: self.decode_qf, + decode_queue_index: 0, + graphics_qf: self.graphics_qf, + } + } + + /// # Safety + /// + /// Every object created from this device — the decoder's session/pools, any + /// readback handles — is already destroyed, and no [`DeviceHandles`] taken + /// from [`Setup::handles`] is still in use. + pub unsafe fn destroy(self) { + // SAFETY: fn contract — nothing derived from these handles survives, so + // the device can be destroyed and then the instance it came from. + unsafe { + self.device.destroy_device(None); + self.instance.destroy_instance(None); + } + // Deliberately LEAK the loader. `ash::Entry` owns an `Arc`, so + // dropping it `dlclose`/`FreeLibrary`s the Vulkan loader together with + // every ICD and implicit layer. That was harmless while each test binary + // held a single GPU leg (the unload was immediately followed by process + // exit), but each binary now holds two legs serialized by `gpu_lock`, so + // the second leg would re-`dlopen` a loader the first just tore down — + // a documented way to fault inside `Entry::load` or to take a signal at + // exit AFTER both legs reported ok, which reads as a decoder defect. + // The process is about to end regardless, so leaking is free. + std::mem::forget(self.entry); + } +} + +/// The optional `PF_VKD_SMOKE_VENDOR` pin: multi-GPU boxes enumerate several +/// decode-capable devices and first-match hides all but one — the pin makes a run +/// attributable to a specific vendor's driver. +fn vendor_pin() -> Option { + std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| { + let trimmed = raw.trim(); + trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16)) + .unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}")) + }) +} + +/// Loader → instance → a physical device that can decode `request.codec` → +/// logical device with the decode extensions and `timelineSemaphore` + +/// `synchronization2`. +/// +/// Panics (the test harness's only failure channel) with the reason when the box +/// cannot host the request; the message names the codec's OWN extension, so a box +/// with H.264 silicon but no H.265 says exactly that rather than "no Vulkan +/// Video". +pub fn bring_up(request: &Request) -> Setup { + // ---- instance ---- + // SAFETY: loads the system Vulkan loader; no Vulkan objects exist yet. + let entry = unsafe { ash::Entry::load() }.expect("a Vulkan loader on this box"); + let app = vk::ApplicationInfo::default().api_version(vk::make_api_version(0, 1, 3, 0)); + let instance_ci = vk::InstanceCreateInfo::default().application_info(&app); + // SAFETY: valid create info rooted in locals; the instance is destroyed by + // `Setup::destroy` after everything created from it. + let instance = + unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance"); + + let vendor_filter = vendor_pin(); + + // ---- physical device with a decode queue family for this codec ---- + // SAFETY: live instance. + let physical_devices = + unsafe { instance.enumerate_physical_devices() }.expect("enumerate physical devices"); + let mut picked: Option<(vk::PhysicalDevice, u32, u32)> = None; + for pd in physical_devices { + // SAFETY: `pd` was just enumerated from this instance. + let props = unsafe { instance.get_physical_device_properties(pd) }; + if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) { + continue; + } + // SAFETY: `pd` was just enumerated from this instance. + let ext_props = + unsafe { instance.enumerate_device_extension_properties(pd) }.unwrap_or_default(); + let has = |name: &std::ffi::CStr| { + ext_props.iter().any(|e| { + e.extension_name_as_c_str() + .is_ok_and(|extension| extension == name) + }) + }; + if !(has(ash::khr::video_queue::NAME) + && has(ash::khr::video_decode_queue::NAME) + && has(request.codec.extension)) + { + continue; + } + // SAFETY: live physical device; the two-call form fills the chained video + // properties for each family. + let family_count = unsafe { instance.get_physical_device_queue_family_properties2_len(pd) }; + let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count]; + let mut families: Vec> = video_props + .iter_mut() + .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) + .collect(); + // SAFETY: as above, arrays sized to the reported count. + unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families) }; + let flags_per_family: Vec = families + .iter() + .map(|f| f.queue_family_properties.queue_flags) + .collect(); + drop(families); // release the &mut borrows so video_props is readable + + // Each family's video ops + RESULT_STATUS query support (see + // `Request::report_families`) — printed per CANDIDATE device, so a + // multi-GPU box reports every device it considered. + if request.report_families { + let mut status_props = + vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count]; + let mut families2: Vec> = status_props + .iter_mut() + .map(|s| vk::QueueFamilyProperties2::default().push_next(s)) + .collect(); + // SAFETY: as the query above. + unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families2) }; + drop(families2); + for (i, s) in status_props.iter().enumerate() { + eprintln!( + "family {i}: flags={:?} video_ops={:?} query_result_status={}", + flags_per_family[i], + video_props[i].video_codec_operations, + s.query_result_status_support != vk::FALSE, + ); + } + } + + let mut decode_qf = None; + let mut graphics_qf = None; + for (index, flags) in flags_per_family.iter().enumerate() { + if flags.contains(vk::QueueFlags::GRAPHICS) && graphics_qf.is_none() { + graphics_qf = Some(index as u32); + } + if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) + && video_props[index] + .video_codec_operations + .contains(request.codec.op) + && decode_qf.is_none() + { + decode_qf = Some(index as u32); + } + } + match (request.graphics, decode_qf, graphics_qf) { + // A graphics queue is required and present. + (Graphics::Required, Some(decode), Some(graphics)) => { + picked = Some((pd, decode, graphics)); + break; + } + // Not required: fall back to the decode family (see the variant docs). + (Graphics::DecodeFamilyIsFine, Some(decode), graphics) => { + picked = Some((pd, decode, graphics.unwrap_or(decode))); + break; + } + // No decode family for this codec, or none of the graphics kind + // required — keep looking. + (Graphics::Required, _, None) | (_, None, _) => {} + } + } + let (pd, decode_qf, graphics_qf) = picked.unwrap_or_else(|| { + panic!( + "no physical device with {} and a decode queue{}{}", + request.codec.extension.to_string_lossy(), + match request.graphics { + Graphics::Required => " and a graphics queue", + Graphics::DecodeFamilyIsFine => "", + }, + match vendor_filter { + Some(vendor) => format!(" (PF_VKD_SMOKE_VENDOR pinned vendor 0x{vendor:04x})"), + None => String::new(), + }, + ) + }); + + // Attribution header: which device (and driver) this run actually exercised. + { + let mut driver_props = vk::PhysicalDeviceDriverProperties::default(); + let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props); + // SAFETY: live physical device; the chain fills the Vulkan 1.2 core + // driver-identity struct. + unsafe { instance.get_physical_device_properties2(pd, &mut props2) }; + let props = props2.properties; + eprintln!( + "picked: {:?} vendor=0x{:04x} driver={:?} info={:?}", + props.device_name_as_c_str().unwrap_or(c"?"), + props.vendor_id, + driver_props.driver_name_as_c_str().unwrap_or(c"?"), + driver_props.driver_info_as_c_str().unwrap_or(c"?"), + ); + } + + // ---- logical device: decode (+ graphics) queues, video + sync features ---- + let priorities = [1.0f32]; + let mut queue_infos = vec![vk::DeviceQueueCreateInfo::default() + .queue_family_index(decode_qf) + .queue_priorities(&priorities)]; + if graphics_qf != decode_qf { + queue_infos.push( + vk::DeviceQueueCreateInfo::default() + .queue_family_index(graphics_qf) + .queue_priorities(&priorities), + ); + } + let extensions = [ + ash::khr::video_queue::NAME.as_ptr(), + ash::khr::video_decode_queue::NAME.as_ptr(), + request.codec.extension.as_ptr(), + ]; + let mut features12 = vk::PhysicalDeviceVulkan12Features::default().timeline_semaphore(true); + let mut features13 = vk::PhysicalDeviceVulkan13Features::default().synchronization2(true); + let device_ci = vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_infos) + .enabled_extension_names(&extensions) + .push_next(&mut features12) + .push_next(&mut features13); + // SAFETY: live physical device, valid create info rooted in locals; destroyed + // by `Setup::destroy` after the decoder drops. + let device = + unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device"); + + Setup { + entry, + instance, + pd, + device, + decode_qf, + graphics_qf, + } +} diff --git a/crates/pf-vkdecode/tests/gpu_parity.rs b/crates/pf-vkdecode/tests/gpu_parity.rs index a781a585..66c43406 100644 --- a/crates/pf-vkdecode/tests/gpu_parity.rs +++ b/crates/pf-vkdecode/tests/gpu_parity.rs @@ -1,5 +1,6 @@ -//! GPU frame-hash parity test (WP-D) — `#[ignore]`d because it needs real -//! Vulkan Video hardware. +//! GPU frame-hash parity tests (WP-D) — the two decode legs are `#[ignore]`d +//! because they need real Vulkan Video hardware; the coherence guards at the +//! bottom of this file are not, and run in ordinary CI. //! //! Run on a Vulkan-Video box with: //! @@ -9,86 +10,76 @@ //! //! (RADV boxes additionally need `RADV_PERFTEST=video_decode`; multi-GPU boxes //! pin the vendor with `PF_VKD_SMOKE_VENDOR=0x1002` / `0x10de`, same knob as -//! the smoke test.) +//! the smoke tests. Device bring-up lives in `tests/common/mod.rs`.) //! -//! What it proves: H.264 decoding is exactly specified — every conformant -//! decoder must produce bit-identical output — so the vendored 25fps vector is -//! decoded through [`VkH264Decoder`], every output frame's NV12 planes are read -//! back (`vkCmdCopyImageToBuffer` on the graphics queue — GPU→CPU is fine in a -//! test; the pool grows TRANSFER_SRC via the decoder's `PF_VKD_TEST_READBACK` -//! hook), cropped to the display region, SHA-256-hashed in DISPLAY order and -//! compared against goldens from libavcodec's SOFTWARE decoder (the reference -//! implementation — provenance in `data/test-25fps.nv12.sha256`). ALL frames -//! are collected, including the tail [`VkH264Decoder::flush`] delivers, and the -//! frame count must match libavcodec's too. +//! What they prove: H.264 and H.265 decoding are both exactly specified — every +//! conformant decoder must produce bit-identical output — so the vendored 25fps +//! vector of each codec is decoded through [`VkH264Decoder`] / [`VkH265Decoder`], +//! every output frame's NV12 planes are read back (`vkCmdCopyImageToBuffer` on the +//! graphics queue — GPU→CPU is fine in a test; the pool grows TRANSFER_SRC via the +//! decoders' `PF_VKD_TEST_READBACK` hook), cropped to the display region, +//! SHA-256-hashed in DISPLAY order and compared against goldens from libavcodec's +//! SOFTWARE decoder (the reference implementation — provenance in +//! `data/test-25fps.nv12.sha256` and `data/test-25fps-h265.nv12.sha256`). ALL +//! frames are collected, including the tail `flush` delivers, and the frame count +//! must match libavcodec's too. +//! +//! Both legs run ONE body ([`collect_hashes`]) over `common::TestDecoder`, so the +//! H.265 leg cannot quietly test something weaker than the H.264 one. A box that +//! decodes only one codec runs that leg and reports the other as "no physical +//! device with VK_KHR_video_decode_…", which is a fact about the box. //! //! The readback follows the presenter's exact frame contract: wait the frame's //! timeline `value`, round-trip the layout, signal `value + 1` in the SAME //! submission, then `release_frame(frame, true)` — and every submission is //! host-waited before the next decode, so nothing here races the decode queue. //! -//! Reading a failure: frame 0 is IDR-only — if it already mismatches, suspect +//! Reading a failure: frame 0 is intra-only — if it already mismatches, suspect //! the readback geometry (row pitch / crop) or intra decode; mismatches that //! only appear on later frames point at inter prediction / DPB management. #![deny(clippy::undocumented_unsafe_blocks)] -use std::io::Cursor; +mod common; use ash::vk; -use ash::vk::Handle; -use cros_codecs::codec::h264::parser::Nalu; -use cros_codecs::codec::h264::parser::NaluType; +use common::TestDecoder; use pf_vkdecode::DecodeStatus; use pf_vkdecode::DecodedVkFrame; -use pf_vkdecode::DeviceHandles; use pf_vkdecode::NoopQueueLock; use pf_vkdecode::VkH264Decoder; +use pf_vkdecode::VkH265Decoder; use sha2::Digest; -// The same vendored vector the WP-A tests convert, same relative path. -const TEST_25FPS: &[u8] = include_bytes!( - "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" -); +/// Golden SHA-256 per display-order frame of the H.264 vector, from libavcodec +/// software decode (generation command + ffmpeg version in the file's header). +const GOLDENS_H264: &str = include_str!("data/test-25fps.nv12.sha256"); -/// Golden SHA-256 per display-order frame, from libavcodec software decode -/// (generation command + ffmpeg version in the file's header). -const GOLDENS: &str = include_str!("data/test-25fps.nv12.sha256"); +/// The H.265 twin, cross-checked between two independent FFmpeg builds (header). +const GOLDENS_H265: &str = include_str!("data/test-25fps-h265.nv12.sha256"); -/// The vector's display (conformance-window) size; the goldens hash exactly -/// this region as tightly packed NV12. -const DISPLAY_W: u32 = 320; -const DISPLAY_H: u32 = 240; -const FRAME_BYTES: usize = (DISPLAY_W * DISPLAY_H * 3 / 2) as usize; +/// The H.264 vector's display (conformance-window) region; the goldens hash +/// exactly this as tightly packed NV12. +const DISPLAY_H264: (u32, u32) = (320, 240); -/// Test-only AU splitter, mirroring pf-bitstream's (`#[cfg(test)]`-private there). -fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { - let mut aus = Vec::new(); - let mut cursor = Cursor::new(stream); - let mut au_start = 0usize; - let mut au_has_slice = false; +/// The H.265 vector's display region. Its SPS carries NO conformance window at +/// all, so this is also its coded size (golden header) — the two vectors merely +/// HAPPEN to share dimensions, which is why [`Readback`] takes the size as a +/// parameter instead of reading one global pair. +const DISPLAY_H265: (u32, u32) = (320, 240); - while let Ok(nalu) = Nalu::next(&mut cursor) { - let nalu_offset = cursor.position() as usize; - let start = nalu_offset - nalu.offset; - let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr); - let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0); +/// Both vectors' picture format: 8-bit 4:2:0. H.264 is NV12 by envelope +/// (`derive_caps` wants nothing else), H.265 Main resolves to it from the SPS — +/// and [`DecodedVkFrame::format`] exists precisely so a pool misconfigured to +/// P010 fails loudly instead of hashing differently. +const EXPECTED_FORMAT: vk::Format = pf_vkdecode::NV12; - if au_has_slice && (!is_slice || first_mb_zero) { - aus.push(&stream[au_start..start]); - au_start = start; - au_has_slice = false; - } - au_has_slice |= is_slice; - } - aus.push(&stream[au_start..]); - aus -} +/// Every vendored 25fps vector, in both codecs, is 250 display frames. +const FRAME_COUNT: usize = 250; /// The golden file's hash lines (comments and blanks skipped). -fn golden_hashes() -> Vec<&'static str> { - GOLDENS - .lines() +fn golden_hashes(file: &'static str) -> Vec<&'static str> { + file.lines() .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .collect() @@ -110,6 +101,11 @@ fn sha256_hex(data: &[u8]) -> String { /// of its video layout, copy, restore the layout, signal `value + 1` in the /// same submission — and is host-waited (fence) before returning, so the test /// stays fully serialized against the decode queue. +/// +/// The display size is a CONSTRUCTION parameter, not a module constant: it sizes +/// the staging buffer and is the crop every read asserts against, and the two +/// vectors sharing 320x240 today is a coincidence that must not become the next +/// vector's silent corruption. struct Readback { device: ash::Device, queue: vk::Queue, @@ -119,6 +115,10 @@ struct Readback { buffer: vk::Buffer, memory: vk::DeviceMemory, mapped: *const u8, + /// The display region every read copies, and the crop it requires. + display: (u32, u32), + /// `w * h * 3 / 2` — the tightly packed NV12 frame this buffer holds. + frame_bytes: usize, } impl Readback { @@ -132,7 +132,18 @@ impl Readback { pd: vk::PhysicalDevice, device: &ash::Device, graphics_qf: u32, + display: (u32, u32), ) -> Self { + let (width, height) = display; + // The two-plane copy below halves both dimensions for the R8G8 plane, so + // an odd display region would silently drop a chroma row/column. + assert_eq!( + (width % 2, height % 2), + (0, 0), + "the display region must be chroma-aligned" + ); + let frame_bytes = (width * height * 3 / 2) as usize; + // SAFETY: fn contract — live device, queue 0 of this family exists. let queue = unsafe { device.get_device_queue(graphics_qf, 0) }; let pool_ci = vk::CommandPoolCreateInfo::default() @@ -153,7 +164,7 @@ impl Readback { .expect("create the readback fence"); let buffer_ci = vk::BufferCreateInfo::default() - .size(FRAME_BYTES as u64) + .size(frame_bytes as u64) .usage(vk::BufferUsageFlags::TRANSFER_DST) .sharing_mode(vk::SharingMode::EXCLUSIVE); // SAFETY: live device; destroyed in `destroy`. @@ -198,6 +209,8 @@ impl Readback { buffer, memory, mapped, + display, + frame_bytes, } } @@ -210,14 +223,16 @@ impl Readback { /// # Safety /// /// `frame` was delivered by a decoder on this device and is not yet - /// released; its image carries TRANSFER_SRC usage (the decoder's + /// released; its image carries TRANSFER_SRC usage (the decoders' /// `PF_VKD_TEST_READBACK` hook); no other work uses the graphics queue or /// this frame's image concurrently (the test is fully serialized). unsafe fn read_nv12(&self, frame: &DecodedVkFrame) -> Vec { + let (width, height) = self.display; assert_eq!( (frame.crop.width, frame.crop.height), - (DISPLAY_W, DISPLAY_H), - "the vector's display size (goldens hash exactly this region)" + self.display, + "the vector's display size this readback was built for (the goldens \ + hash exactly this region)" ); assert_eq!( (frame.crop.x % 2, frame.crop.y % 2), @@ -279,13 +294,13 @@ impl Readback { z: 0, }, image_extent: vk::Extent3D { - width: DISPLAY_W, - height: DISPLAY_H, + width, + height, depth: 1, }, }, vk::BufferImageCopy { - buffer_offset: u64::from(DISPLAY_W * DISPLAY_H), + buffer_offset: u64::from(width * height), buffer_row_length: 0, buffer_image_height: 0, image_subresource: layers(vk::ImageAspectFlags::PLANE_1), @@ -295,14 +310,14 @@ impl Readback { z: 0, }, image_extent: vk::Extent3D { - width: DISPLAY_W / 2, - height: DISPLAY_H / 2, + width: width / 2, + height: height / 2, depth: 1, }, }, ]; // SAFETY: the image is in TRANSFER_SRC_OPTIMAL via the barrier above and - // carries TRANSFER_SRC usage (fn contract); the buffer's FRAME_BYTES + // carries TRANSFER_SRC usage (fn contract); the buffer's `frame_bytes` // exactly spans the two packed regions. unsafe { self.device.cmd_copy_image_to_buffer( @@ -375,10 +390,10 @@ impl Readback { // SAFETY: the fence was observed signalled above. unsafe { self.device.reset_fences(&[self.fence]) }.expect("reset the readback fence"); - // SAFETY: `mapped` points at FRAME_BYTES host-coherent bytes; the fence - // wait (plus the HOST_READ barrier) ordered the device writes before - // this host read. - unsafe { std::slice::from_raw_parts(self.mapped, FRAME_BYTES) }.to_vec() + // SAFETY: `mapped` points at `frame_bytes` host-coherent bytes (the + // buffer was created at that size); the fence wait (plus the HOST_READ + // barrier) ordered the device writes before this host read. + unsafe { std::slice::from_raw_parts(self.mapped, self.frame_bytes) }.to_vec() } /// # Safety @@ -401,7 +416,7 @@ impl Readback { /// the presenter write-back the readback enqueued). `index` is the display /// index the hash will land at. fn consume_frame( - decoder: &mut VkH264Decoder, + decoder: &mut impl TestDecoder, readback: &Readback, frame: &DecodedVkFrame, index: usize, @@ -412,6 +427,13 @@ fn consume_frame( "frame {index}: decode op not COMPLETE\n state: {}", decoder.debug_snapshot() ); + // A pool built for the wrong picture format would decode correctly and then + // hash differently for a reason no mismatch report could explain + // (`DecodedVkFrame::format` docs) — refuse it here instead. + assert_eq!( + frame.format, EXPECTED_FORMAT, + "frame {index}: 8-bit 4:2:0 vector must decode into an NV12 pool" + ); // SAFETY: the frame is delivered and unreleased on the readback's device; // the pool carries TRANSFER_SRC (PF_VKD_TEST_READBACK was set before the // decoder's first decode); the test is fully serialized, so nothing else @@ -423,217 +445,49 @@ fn consume_frame( sha256_hex(&nv12) } -#[test] -#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"] -fn every_frame_hashes_bit_identical_to_libavcodec() { - // The decoder reads this at session creation (first decode call): pool - // images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal. - std::env::set_var("PF_VKD_TEST_READBACK", "1"); - - let goldens = golden_hashes(); - assert_eq!( - goldens.len(), - 250, - "the golden file carries one hash per libavcodec frame" - ); - - // ---- instance ---- - // SAFETY: loads the system Vulkan loader; no Vulkan objects exist yet. - let entry = unsafe { ash::Entry::load() }.expect("a Vulkan loader on this box"); - let app = vk::ApplicationInfo::default().api_version(vk::make_api_version(0, 1, 3, 0)); - let instance_ci = vk::InstanceCreateInfo::default().application_info(&app); - // SAFETY: valid create info rooted in locals; the instance is destroyed at the - // end of this test after everything created from it. - let instance = - unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance"); - - // Optional vendor pin (`PF_VKD_SMOKE_VENDOR`, hex `0x1002` or decimal) — the - // smoke test's knob, same semantics: makes multi-GPU runs attributable. - let vendor_filter: Option = std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| { - let trimmed = raw.trim(); - trimmed - .strip_prefix("0x") - .or_else(|| trimmed.strip_prefix("0X")) - .map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16)) - .unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}")) - }); - - // ---- physical device with an H.264 decode queue family ---- - // SAFETY: live instance. - let physical_devices = - unsafe { instance.enumerate_physical_devices() }.expect("enumerate physical devices"); - let mut picked: Option<(vk::PhysicalDevice, u32, u32)> = None; - for pd in physical_devices { - // SAFETY: `pd` was just enumerated from this instance. - let props = unsafe { instance.get_physical_device_properties(pd) }; - if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) { - continue; - } - // SAFETY: `pd` was just enumerated from this instance. - let ext_props = - unsafe { instance.enumerate_device_extension_properties(pd) }.unwrap_or_default(); - let has = |name: &std::ffi::CStr| { - ext_props.iter().any(|e| { - e.extension_name_as_c_str() - .is_ok_and(|extension| extension == name) - }) - }; - if !(has(ash::khr::video_queue::NAME) - && has(ash::khr::video_decode_queue::NAME) - && has(ash::khr::video_decode_h264::NAME)) - { - continue; - } - // SAFETY: live physical device; the two-call form fills the chained video - // properties for each family. - let family_count = unsafe { instance.get_physical_device_queue_family_properties2_len(pd) }; - let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count]; - let mut families: Vec> = video_props - .iter_mut() - .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) - .collect(); - // SAFETY: as above, arrays sized to the reported count. - unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families) }; - let flags_per_family: Vec = families - .iter() - .map(|f| f.queue_family_properties.queue_flags) - .collect(); - drop(families); // release the &mut borrows so video_props is readable - - let mut decode_qf = None; - let mut graphics_qf = None; - for (index, flags) in flags_per_family.iter().enumerate() { - if flags.contains(vk::QueueFlags::GRAPHICS) && graphics_qf.is_none() { - graphics_qf = Some(index as u32); - } - if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) - && video_props[index] - .video_codec_operations - .contains(vk::VideoCodecOperationFlagsKHR::DECODE_H264) - && decode_qf.is_none() - { - decode_qf = Some(index as u32); - } - } - // Unlike the smoke test this one NEEDS a graphics queue (the readback - // runs there), so a device without one is skipped, not defaulted. - if let (Some(decode), Some(graphics)) = (decode_qf, graphics_qf) { - picked = Some((pd, decode, graphics)); - break; - } - } - let (pd, decode_qf, graphics_qf) = picked.expect( - "a physical device with VK_KHR_video_decode_h264, a decode queue and a graphics queue", - ); - - // Attribution header: which device (and driver) this run actually exercised. - { - let mut driver_props = vk::PhysicalDeviceDriverProperties::default(); - let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props); - // SAFETY: live physical device; the chain fills the Vulkan 1.2 core - // driver-identity struct. - unsafe { instance.get_physical_device_properties2(pd, &mut props2) }; - let props = props2.properties; - eprintln!( - "picked: {:?} vendor=0x{:04x} driver={:?} info={:?}", - props.device_name_as_c_str().unwrap_or(c"?"), - props.vendor_id, - driver_props.driver_name_as_c_str().unwrap_or(c"?"), - driver_props.driver_info_as_c_str().unwrap_or(c"?"), - ); - } - - // ---- logical device: decode (+ graphics) queues, video + sync features ---- - let priorities = [1.0f32]; - let mut queue_infos = vec![vk::DeviceQueueCreateInfo::default() - .queue_family_index(decode_qf) - .queue_priorities(&priorities)]; - if graphics_qf != decode_qf { - queue_infos.push( - vk::DeviceQueueCreateInfo::default() - .queue_family_index(graphics_qf) - .queue_priorities(&priorities), - ); - } - let extensions = [ - ash::khr::video_queue::NAME.as_ptr(), - ash::khr::video_decode_queue::NAME.as_ptr(), - ash::khr::video_decode_h264::NAME.as_ptr(), - ]; - let mut features12 = vk::PhysicalDeviceVulkan12Features::default().timeline_semaphore(true); - let mut features13 = vk::PhysicalDeviceVulkan13Features::default().synchronization2(true); - let device_ci = vk::DeviceCreateInfo::default() - .queue_create_infos(&queue_infos) - .enabled_extension_names(&extensions) - .push_next(&mut features12) - .push_next(&mut features13); - // SAFETY: live physical device, valid create info rooted in locals; destroyed - // at the end of this test after the decoder drops. - let device = - unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device"); - - // ---- decode the WHOLE vector, hash every display-order frame ---- - let handles = DeviceHandles { - get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize, - instance: instance.handle().as_raw() as usize, - physical_device: pd.as_raw() as usize, - device: device.handle().as_raw() as usize, - decode_qf, - decode_queue_index: 0, - graphics_qf, - }; +/// Decode every AU, hash every delivered frame in display order, including the +/// tail `flush` hands back. One body for both codecs. +fn collect_hashes( + decoder: &mut impl TestDecoder, + readback: &Readback, + aus: &[&[u8]], +) -> Vec { let mut hashes: Vec = Vec::new(); - { - // SAFETY: the handles above are live for this whole block (the decoder - // drops at its end, before the device/instance destroys below), the device - // was created with the decode extensions + timeline/sync2 features, and - // the queue fields name the families/queues created above. - let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) } - .expect("wrap the device"); - // SAFETY: live instance/device; queue 0 of `graphics_qf` was created - // above; destroyed at the end of this block after its last read. - let readback = unsafe { Readback::new(&instance, pd, &device, graphics_qf) }; - - let aus = split_into_aus(TEST_25FPS); - for (au_index, au) in aus.iter().enumerate() { - let mut next = decoder.decode(au).unwrap_or_else(|e| { - panic!( - "AU {au_index}: decode failed: {e}\n state: {}", - decoder.debug_snapshot() - ) - }); - while let Some(frame) = next { - let hash = consume_frame(&mut decoder, &readback, &frame, hashes.len()); - hashes.push(hash); - next = decoder.take_ready(); - } - } - // The decoder emits in bumping (display) order and the stream may hold - // frames — the flush tail belongs in the comparison too. - decoder.flush(); - while let Some(frame) = decoder.take_ready() { - let hash = consume_frame(&mut decoder, &readback, &frame, hashes.len()); + for (au_index, au) in aus.iter().enumerate() { + let mut next = decoder.decode(au).unwrap_or_else(|e| { + panic!( + "AU {au_index}: decode failed: {e}\n state: {}", + decoder.debug_snapshot() + ) + }); + while let Some(frame) = next { + let hash = consume_frame(decoder, readback, &frame, hashes.len()); hashes.push(hash); + next = decoder.take_ready(); } - eprintln!("final state: {}", decoder.debug_snapshot()); - // SAFETY: every readback was fence-waited inside `read_nv12`; nothing - // else references its handles. - unsafe { readback.destroy() }; } - - // ---- teardown (decoder is gone; its Drop drained the queue) ---- - // SAFETY: every object created from the device (the decoder's pools/session, - // the readback's buffer/pool/fence) was destroyed above. - unsafe { - device.destroy_device(None); - instance.destroy_instance(None); + // The decoders emit in bumping (display) order and a stream may hold frames — + // the flush tail belongs in the comparison too. + decoder.flush(); + while let Some(frame) = decoder.take_ready() { + let hash = consume_frame(decoder, readback, &frame, hashes.len()); + hashes.push(hash); } + eprintln!( + "final state: {} status_queries={}", + decoder.debug_snapshot(), + decoder.status_queries() + ); + hashes +} - // ---- the verdict ---- +/// The verdict, run AFTER teardown so a mismatch panic cannot leave the device +/// alive. +fn assert_bit_identical(hashes: &[String], goldens: &[&str], codec: &str) { assert_eq!( hashes.len(), goldens.len(), - "frame count diverges from libavcodec ({} decoded vs {} golden)", + "{codec}: frame count diverges from libavcodec ({} decoded vs {} golden)", hashes.len(), goldens.len() ); @@ -649,14 +503,296 @@ fn every_frame_hashes_bit_identical_to_libavcodec() { assert_eq!( mismatches, 0, - "{mismatches}/{} frames diverge from libavcodec (first 10 printed above; \ - frame 0 is IDR-only — if IT mismatches, suspect readback geometry \ - (pitch/crop) or intra decode; later-only mismatches point at inter \ - prediction / DPB management)", + "{codec}: {mismatches}/{} frames diverge from libavcodec (first 10 printed \ + above; frame 0 is intra-only — if IT mismatches, suspect readback \ + geometry (pitch/crop) or intra decode; later-only mismatches point at \ + inter prediction / DPB management)", hashes.len() ); eprintln!( - "{} frames bit-identical to libavcodec software decode", + "{codec}: {} frames bit-identical to libavcodec software decode", hashes.len() ); } + +#[test] +#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"] +fn h264_every_frame_hashes_bit_identical_to_libavcodec() { + // One codec at a time on the device, and the `set_var` below happens only + // under this lock (see `common::gpu_lock`). + let _gpu = common::gpu_lock(); + + // The decoder reads this at session creation (first decode call): pool + // images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal. + std::env::set_var("PF_VKD_TEST_READBACK", "1"); + + let goldens = golden_hashes(GOLDENS_H264); + assert_eq!( + goldens.len(), + FRAME_COUNT, + "the golden file carries one hash per libavcodec frame" + ); + + let setup = common::bring_up(&common::Request { + codec: common::H264, + // Unlike the smoke legs this one NEEDS a graphics queue (the readback + // records there), so a device without one is skipped, not defaulted. + graphics: common::Graphics::Required, + report_families: true, + }); + let handles = setup.handles(); + + let hashes = { + // SAFETY: `setup` outlives this block (destroyed below, after the decoder + // and readback drop at the block's end), it was created with the H.264 + // decode extensions + timeline/sync2 features, and its queue fields name + // the families/queues it created. + let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) } + .expect("wrap the device"); + // SAFETY: live instance/device; queue 0 of `graphics_qf` was created by + // the bring-up; destroyed at the end of this block after its last read. + let readback = unsafe { + Readback::new( + &setup.instance, + setup.pd, + &setup.device, + setup.graphics_qf, + DISPLAY_H264, + ) + }; + let hashes = collect_hashes( + &mut decoder, + &readback, + &common::split_h264_aus(common::TEST_25FPS_H264), + ); + // SAFETY: every readback was fence-waited inside `read_nv12`; nothing + // else references its handles. + unsafe { readback.destroy() }; + hashes + }; + + // SAFETY: the decoder is gone (its Drop drained the queue and destroyed its + // session/pools), the readback's handles are destroyed, and nothing else + // references the setup's handles. + unsafe { setup.destroy() }; + + assert_bit_identical(&hashes, &goldens, "H.264"); +} + +#[test] +#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"] +fn h265_every_frame_hashes_bit_identical_to_libavcodec() { + // As the H.264 leg: one codec at a time, `set_var` under the lock. + let _gpu = common::gpu_lock(); + + std::env::set_var("PF_VKD_TEST_READBACK", "1"); + + let goldens = golden_hashes(GOLDENS_H265); + assert_eq!( + goldens.len(), + FRAME_COUNT, + "the golden file carries one hash per libavcodec frame" + ); + + let setup = common::bring_up(&common::Request { + codec: common::H265, + graphics: common::Graphics::Required, + report_families: true, + }); + let handles = setup.handles(); + + let hashes = { + // SAFETY: as the H.264 leg — `setup` outlives this block and was created + // with the H.265 decode extensions + timeline/sync2 features. + let mut decoder = unsafe { VkH265Decoder::new(&handles, Box::new(NoopQueueLock)) } + .expect("wrap the device"); + // The construction-time shape gate the client's ladder relies on, on the + // vector's own facts (Main, 4:2:0, 8-bit → NV12): a device that cannot + // host the combination refuses here with a caps reason instead of failing + // mid-stream. + decoder + .probe_stream_support(1, 0) + .expect("the box must host H.265 Main 8-bit 4:2:0 (the vector's shape)"); + // SAFETY: as the H.264 leg — live instance/device, queue 0 of + // `graphics_qf` exists; destroyed at the end of this block. + let readback = unsafe { + Readback::new( + &setup.instance, + setup.pd, + &setup.device, + setup.graphics_qf, + DISPLAY_H265, + ) + }; + let hashes = collect_hashes( + &mut decoder, + &readback, + &common::split_h265_aus(common::TEST_25FPS_H265), + ); + // SAFETY: every readback was fence-waited inside `read_nv12`; nothing + // else references its handles. + unsafe { readback.destroy() }; + hashes + }; + + // SAFETY: as the H.264 leg — decoder and readback are gone. + unsafe { setup.destroy() }; + + assert_bit_identical(&hashes, &goldens, "H.265"); +} + +// --------------------------------------------------------------------------- +// CPU coherence guards — NOT `#[ignore]`d. +// +// The legs above only run on the fleet, so without these nothing in ordinary CI +// notices that a re-synced vendored vector, a golden regeneration or an edit to +// `common`'s AU splitters has made the two disagree. They would then fail on the +// fleet as a frame-count mismatch, which reads like a decoder defect and costs a +// hardware round trip to disprove. +// +// Each guard pins the whole chain the parity verdict rests on: the AU split, the +// planner's output count, and the golden line count — with NO GPU involved. +// --------------------------------------------------------------------------- + +#[test] +fn h265_goldens_and_au_split_agree_with_the_planner() { + use pf_bitstream::h265::H265Planner; + + let goldens = golden_hashes(GOLDENS_H265); + assert_eq!( + goldens.len(), + FRAME_COUNT, + "data/test-25fps-h265.nv12.sha256 must carry one hash per display frame" + ); + assert!( + goldens + .iter() + .all(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())), + "every golden line is a bare lowercase SHA-256 hex digest" + ); + + // The AU split the parity leg feeds the decoder. `common::split_h265_aus` is + // the copy of pf-bitstream's private splitter, and it keys on HEVC's 2-byte + // NAL header — a `+ 1` there (H.264's offset) silently merges or splits AUs. + let aus = common::split_h265_aus(common::TEST_25FPS_H265); + assert_eq!( + aus.len(), + FRAME_COUNT, + "the vendored H.265 vector is {FRAME_COUNT} access units \ + (pf-bitstream's own planner test pins the same number)" + ); + + // Walk the CPU planner over the same AUs: it is the authority on how many + // frames the GPU leg can possibly deliver, because the decoder builds exactly + // one delivered frame per `dpb.outputs` id (plus the flush tail). + let mut planner = H265Planner::new(); + let mut outputs = 0usize; + let mut iraps = 0usize; + for (index, au) in aus.iter().enumerate() { + let plan = planner.plan_au(au).unwrap_or_else(|e| { + panic!( + "AU {index}: the clean vector must plan without errors, got {e:?} \ + — if this is RaslSkipped the vector has gained CRA/RASL pictures \ + and the parity legs' expected frame count needs rederiving" + ); + }); + outputs += plan.dpb.outputs.len(); + iraps += usize::from(plan.picture.is_irap); + // Pin the picture shape the H.265 legs hard-code. They call + // `probe_stream_support(1, 0)` (4:2:0, 8-bit) and assert the NV12 output + // format; a re-synced Main-10 or 4:4:4 vector would make both of those + // silently probe and expect the WRONG profile on the fleet, which is a + // confusing hardware-only failure. Fail here, on CPU, with the reason. + assert_eq!( + ( + plan.picture.chroma_format_idc, + plan.picture.bit_depth_luma_minus8 + ), + (1, 0), + "AU {index}: the vendored H.265 vector must stay Main 4:2:0 8-bit — \ + the parity and smoke legs hard-code probe_stream_support(1, 0) and \ + an NV12 output format, so a re-synced vector of another shape needs \ + both legs updated, not just the goldens" + ); + if index == 0 { + assert!(plan.picture.is_idr, "the vector opens with an IDR"); + assert_eq!( + (plan.picture.coded_width, plan.picture.coded_height), + DISPLAY_H265, + "the vector is 320x240" + ); + assert_eq!( + ( + plan.picture.display_crop.x, + plan.picture.display_crop.y, + plan.picture.display_crop.width, + plan.picture.display_crop.height, + ), + (0, 0, DISPLAY_H265.0, DISPLAY_H265.1), + "the vector carries NO conformance window — coded size IS display \ + size (the golden header's claim, and what `Readback` asserts)" + ); + } + } + outputs += planner.flush().outputs.len(); + + assert_eq!( + outputs, + goldens.len(), + "the planner outputs {outputs} pictures but the goldens carry {} hashes — \ + the parity leg's frame-count assertion would fail on hardware for a \ + reason that has nothing to do with the GPU", + goldens.len() + ); + // No CRA/BLA anywhere means `PlanError::RaslSkipped` — the Ok-skip that + // returns `Ok(None)` rather than an error (h265 module docs, and + // `VkH265Decoder::decode`'s RASL arm) — is UNREACHABLE on this vector, so the + // count above cannot be perturbed by it. If a re-synced vector ever opens with + // a CRA, this assertion fires first and says where to look. + assert_eq!( + iraps, 1, + "the vector holds exactly one IRAP (the opening IDR); a CRA/BLA would make \ + RASL skips reachable and the expected frame count needs rederiving" + ); +} + +#[test] +fn h264_goldens_and_au_split_agree_with_the_planner() { + use pf_bitstream::h264::H264Planner; + + let goldens = golden_hashes(GOLDENS_H264); + assert_eq!( + goldens.len(), + FRAME_COUNT, + "data/test-25fps.nv12.sha256 must carry one hash per display frame" + ); + assert!( + goldens + .iter() + .all(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())), + "every golden line is a bare lowercase SHA-256 hex digest" + ); + + let aus = common::split_h264_aus(common::TEST_25FPS_H264); + assert_eq!( + aus.len(), + FRAME_COUNT, + "the vendored H.264 vector is {FRAME_COUNT} access units" + ); + + let mut planner = H264Planner::new(); + let mut outputs = 0usize; + for (index, au) in aus.iter().enumerate() { + let plan = planner + .plan_au(au) + .unwrap_or_else(|e| panic!("AU {index}: the clean vector must plan, got {e:?}")); + outputs += plan.dpb.outputs.len(); + } + outputs += planner.flush().outputs.len(); + assert_eq!( + outputs, + goldens.len(), + "the planner outputs {outputs} pictures but the goldens carry {} hashes", + goldens.len() + ); +} diff --git a/crates/pf-vkdecode/tests/gpu_smoke.rs b/crates/pf-vkdecode/tests/gpu_smoke.rs index 317023ca..2f95d07f 100644 --- a/crates/pf-vkdecode/tests/gpu_smoke.rs +++ b/crates/pf-vkdecode/tests/gpu_smoke.rs @@ -1,4 +1,4 @@ -//! GPU smoke test — `#[ignore]`d because it needs real Vulkan Video hardware. +//! GPU smoke tests — `#[ignore]`d because they need real Vulkan Video hardware. //! //! Run on a Vulkan-Video box with: //! @@ -10,11 +10,18 @@ //! any machine like them): //! - a Vulkan 1.3 loader on the library path (`libvulkan.so.1` / `vulkan-1.dll`); //! - a physical device advertising `VK_KHR_video_queue`, -//! `VK_KHR_video_decode_queue` and `VK_KHR_video_decode_h264`, with a queue -//! family carrying `VIDEO_DECODE_KHR` ops for H.264; +//! `VK_KHR_video_decode_queue` and the leg's codec extension +//! (`VK_KHR_video_decode_h264` / `VK_KHR_video_decode_h265`), with a queue +//! family carrying `VIDEO_DECODE_KHR` ops for that codec; //! - `timelineSemaphore` + `synchronization2` feature support (Vulkan 1.3 core). //! -//! What it proves: device wrap → caps query/derivation on REAL caps → session + +//! One leg per codec, running the SAME body ([`smoke`]) over the vendored 25fps +//! vector of that codec — a box that decodes only one of the two runs that leg and +//! reports the other as "no physical device with VK_KHR_video_decode_…", which is +//! a fact about the box rather than a failure. Device bring-up lives in +//! `tests/common/mod.rs`. +//! +//! What they prove: device wrap → caps query/derivation on REAL caps → session + //! parameters creation → the decoupled picture pool → 48 AUs of the vendored //! 25fps vector decoded through `vkCmdDecodeVideoKHR` — well past DPB-full, so //! slot re-activation binds fresh pool images repeatedly — while the consumer @@ -23,308 +30,300 @@ //! Every frame's RESULT_STATUS_ONLY query must read COMPLETE before its //! release. This is the regression test for the .25 field failure class: any //! pool sizing that ignores the stream's DPB depth or the client's hold depth -//! starves exactly here. What it deliberately does NOT prove (WP-D on-glass): -//! pixel correctness vs the ffmpeg rung, presenter interop (the `value + 1` -//! signal-back — no presenter runs here, so releases pass `false`), soak, and -//! both vendors' DPB arrangements at once (each box exercises only its own). +//! starves exactly here. What they deliberately do NOT prove (that is +//! `gpu_parity`'s and WP-D on-glass's ground): pixel correctness vs the ffmpeg +//! rung, presenter interop (the `value + 1` signal-back — no presenter runs here, +//! so releases pass `false`), soak, and both vendors' DPB arrangements at once +//! (each box exercises only its own). #![deny(clippy::undocumented_unsafe_blocks)] -use std::io::Cursor; +mod common; use ash::vk; -use ash::vk::Handle; -use cros_codecs::codec::h264::parser::Nalu; -use cros_codecs::codec::h264::parser::NaluType; +use common::TestDecoder; use pf_vkdecode::DecodeStatus; -use pf_vkdecode::DeviceHandles; +use pf_vkdecode::DecodedVkFrame; use pf_vkdecode::NoopQueueLock; use pf_vkdecode::VkH264Decoder; +use pf_vkdecode::VkH265Decoder; -// The same vendored vector the WP-A tests convert, same relative path. -const TEST_25FPS: &[u8] = include_bytes!( - "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" -); +/// AUs fed: far past either vector's DPB depth (`max_dpb_frames = 7` for the +/// H.264 clip), so DPB slots re-activate onto fresh pool images repeatedly. +const AUS: usize = 48; +/// The REAL client's consumption shape: the consumer holds four delivered frames +/// and releases only the oldest beyond that (its channels + preroll + in-flight +/// present hold ~4-7). +const CLIENT_HOLD: usize = 4; +/// 48 AUs may legitimately leave a few pictures buffered for reorder; anything +/// below this is a delivery failure, not reordering. +const MIN_DELIVERED: usize = 40; -/// Test-only AU splitter, mirroring pf-bitstream's (`#[cfg(test)]`-private there). -fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { - let mut aus = Vec::new(); - let mut cursor = Cursor::new(stream); - let mut au_start = 0usize; - let mut au_has_slice = false; +/// The geometry one leg's vector must deliver. +struct Geometry { + /// The vector's display (conformance-window) region. + display: (u32, u32), + /// The ALLOCATED extent, when the leg knows it for a fact. + /// + /// `pictureAccessGranularity` rounds the coded size up, so this is a + /// per-vector AND per-driver fact, not a property of the bitstream. The H.264 + /// leg has asserted `(320, 240)` on the fleet since WP-B and keeps asserting + /// it; the H.265 leg has NO hardware evidence yet, so it asserts only the + /// invariant that always holds (allocated >= display) and PRINTS what it got + /// — which is exactly what a first fleet run needs in order to pin it later. + exact_coded: Option<(u32, u32)>, +} - while let Ok(nalu) = Nalu::next(&mut cursor) { - let nalu_offset = cursor.position() as usize; - let start = nalu_offset - nalu.offset; - let is_slice = matches!(nalu.header.type_, NaluType::Slice | NaluType::SliceIdr); - let first_mb_zero = is_slice && stream.get(nalu_offset + 1).is_some_and(|b| b & 0x80 != 0); - - if au_has_slice && (!is_slice || first_mb_zero) { - aus.push(&stream[au_start..start]); - au_start = start; - au_has_slice = false; +/// Decode [`AUS`] access units while holding [`CLIENT_HOLD`] frames, asserting the +/// decode verdict of every frame before its release. +/// +/// One body for both codecs (over `common::TestDecoder`) so "the H.265 leg proves +/// what the H.264 leg proves" is structural rather than a claim about two copies. +fn smoke(decoder: &mut impl TestDecoder, aus: &[&[u8]], geometry: &Geometry) { + // The smoke legs exist to prove the PRODUCTION pool arrangement survives 48 + // AUs at the client's hold depth. `PF_VKD_TEST_READBACK` adds TRANSFER_SRC to + // the picture pool for whoever sets it, so a shell that exported it while + // iterating on the parity legs would quietly test a pool production never + // builds — and the leg would still pass. Refuse rather than mislead. + assert!( + std::env::var_os("PF_VKD_TEST_READBACK").is_none(), + "PF_VKD_TEST_READBACK is set in the environment: it grows the picture pool \ + a usage flag production never carries, so this leg would no longer be \ + testing the production pool arrangement. Unset it for the smoke legs \ + (the parity legs set it themselves, under the same GPU lock)." + ); + // Status is read (COMPLETE required, the program's whole point) as each frame + // retires; `take_ready` is drained every AU so nothing is stranded. No + // presenter runs here, so releases report `presenter_signaled = false` (no + // `value + 1` write-back). + let mut held: std::collections::VecDeque = std::collections::VecDeque::new(); + let mut delivered = 0usize; + let mut geometry_checked = false; + for (index, au) in aus.iter().enumerate().take(AUS) { + let mut next = decoder.decode(au).unwrap_or_else(|e| { + panic!( + "AU {index}: decode failed: {e}\n state: {}", + decoder.debug_snapshot() + ) + }); + while let Some(frame) = next { + if !geometry_checked { + assert_eq!( + (frame.crop.width, frame.crop.height), + geometry.display, + "the vector's display region" + ); + assert!( + frame.coded_width >= frame.crop.width + && frame.coded_height >= frame.crop.height, + "the ALLOCATED extent ({}x{}) must cover the display region ({}x{})", + frame.coded_width, + frame.coded_height, + frame.crop.width, + frame.crop.height, + ); + if let Some(exact) = geometry.exact_coded { + assert_eq!( + (frame.coded_width, frame.coded_height), + exact, + "ALLOCATED extent (this vector needs no granularity padding here)" + ); + } + // A pool built for the wrong picture format decodes and then + // renders with the wrong maths (`DecodedVkFrame::format` docs); + // both vectors are 8-bit 4:2:0, so both must land on NV12. + assert_eq!( + frame.format, + pf_vkdecode::NV12, + "8-bit 4:2:0 vector must decode into an NV12 pool" + ); + assert_ne!(frame.image, vk::Image::null()); + assert_ne!(frame.semaphore, vk::Semaphore::null()); + assert!(frame.value > 0); + eprintln!( + "geometry: allocated {}x{} display {}x{} format {:?} layout {:?}", + frame.coded_width, + frame.coded_height, + frame.crop.width, + frame.crop.height, + frame.format, + frame.layout, + ); + geometry_checked = true; + } + held.push_back(frame); + delivered += 1; + // Steady state: keep CLIENT_HOLD frames in hand, retire beyond. + while held.len() > CLIENT_HOLD { + let oldest = held.pop_front().expect("nonempty"); + assert_eq!( + decoder.wait_status(&oldest), + DecodeStatus::Ok, + "AU {index}: decode op not COMPLETE\n state: {}", + decoder.debug_snapshot() + ); + decoder + .release_frame(&oldest, false) + .unwrap_or_else(|e| panic!("AU {index}: release failed: {e}")); + } + next = decoder.take_ready(); } - au_has_slice |= is_slice; } - aus.push(&stream[au_start..]); - aus + // Retire the tail the consumer still holds. + for frame in held.drain(..) { + assert_eq!(decoder.wait_status(&frame), DecodeStatus::Ok); + decoder + .release_frame(&frame, false) + .expect("tail frames release"); + } + assert!( + delivered >= MIN_DELIVERED, + "expected at least {MIN_DELIVERED} delivered frames from {AUS} AUs, got {delivered}" + ); + // The DPB mode the caps derivation chose, and whether this box answers per-op + // status at all — a passing run should say so too (failure paths already carry + // the snapshot). + eprintln!( + "final state: {} status_queries={}", + decoder.debug_snapshot(), + decoder.status_queries() + ); } #[test] #[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"] -fn decodes_48_aus_holding_four_frames_like_the_real_client() { - // ---- instance ---- - // SAFETY: loads the system Vulkan loader; no Vulkan objects exist yet. - let entry = unsafe { ash::Entry::load() }.expect("a Vulkan loader on this box"); - let app = vk::ApplicationInfo::default().api_version(vk::make_api_version(0, 1, 3, 0)); - let instance_ci = vk::InstanceCreateInfo::default().application_info(&app); - // SAFETY: valid create info rooted in locals; the instance is destroyed at the - // end of this test after everything created from it. - let instance = - unsafe { entry.create_instance(&instance_ci, None) }.expect("create a Vulkan 1.3 instance"); +fn h264_decodes_48_aus_holding_four_frames_like_the_real_client() { + // One codec at a time on the device (see `common::gpu_lock`). + let _gpu = common::gpu_lock(); - // Optional vendor pin (`PF_VKD_SMOKE_VENDOR`, hex `0x1002` or decimal): multi-GPU - // boxes enumerate several decode-capable devices and first-match hides all but - // one — the pin makes a run attributable to a specific vendor's driver. - let vendor_filter: Option = std::env::var("PF_VKD_SMOKE_VENDOR").ok().map(|raw| { - let trimmed = raw.trim(); - trimmed - .strip_prefix("0x") - .or_else(|| trimmed.strip_prefix("0X")) - .map_or_else(|| trimmed.parse(), |hex| u32::from_str_radix(hex, 16)) - .unwrap_or_else(|_| panic!("PF_VKD_SMOKE_VENDOR is not a PCI vendor id: {raw:?}")) + let setup = common::bring_up(&common::Request { + codec: common::H264, + // The smoke legs submit nothing outside the decoder, so a decode-only + // device is usable (and its EXCLUSIVE pool sharing is worth exercising). + graphics: common::Graphics::DecodeFamilyIsFine, + report_families: true, }); - - // ---- physical device with an H.264 decode queue family ---- - // SAFETY: live instance. - let physical_devices = - unsafe { instance.enumerate_physical_devices() }.expect("enumerate physical devices"); - let mut picked: Option<(vk::PhysicalDevice, u32, u32)> = None; - for pd in physical_devices { - // SAFETY: `pd` was just enumerated from this instance. - let props = unsafe { instance.get_physical_device_properties(pd) }; - if vendor_filter.is_some_and(|vendor| props.vendor_id != vendor) { - continue; - } - // SAFETY: `pd` was just enumerated from this instance. - let ext_props = - unsafe { instance.enumerate_device_extension_properties(pd) }.unwrap_or_default(); - let has = |name: &std::ffi::CStr| { - ext_props.iter().any(|e| { - e.extension_name_as_c_str() - .is_ok_and(|extension| extension == name) - }) - }; - if !(has(ash::khr::video_queue::NAME) - && has(ash::khr::video_decode_queue::NAME) - && has(ash::khr::video_decode_h264::NAME)) - { - continue; - } - // SAFETY: live physical device; the two-call form fills the chained video - // properties for each family. - let family_count = unsafe { instance.get_physical_device_queue_family_properties2_len(pd) }; - let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count]; - let mut families: Vec> = video_props - .iter_mut() - .map(|v| vk::QueueFamilyProperties2::default().push_next(v)) - .collect(); - // SAFETY: as above, arrays sized to the reported count. - unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families) }; - let flags_per_family: Vec = families - .iter() - .map(|f| f.queue_family_properties.queue_flags) - .collect(); - drop(families); // release the &mut borrows so video_props is readable - - // Print each family's video ops + RESULT_STATUS query support — the - // context every failure report needs first (which mode the box runs and - // whether per-op status verdicts even exist here; RADV: they do not, - // and recording one hangs the VCN — the 2026-08 .25 lesson). - { - let mut status_props = - vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count]; - let mut families2: Vec> = status_props - .iter_mut() - .map(|s| vk::QueueFamilyProperties2::default().push_next(s)) - .collect(); - // SAFETY: as the query above. - unsafe { instance.get_physical_device_queue_family_properties2(pd, &mut families2) }; - drop(families2); - for (i, s) in status_props.iter().enumerate() { - eprintln!( - "family {i}: flags={:?} video_ops={:?} query_result_status={}", - flags_per_family[i], - video_props[i].video_codec_operations, - s.query_result_status_support != vk::FALSE, - ); - } - } - - let mut decode_qf = None; - let mut graphics_qf = None; - for (index, flags) in flags_per_family.iter().enumerate() { - if flags.contains(vk::QueueFlags::GRAPHICS) && graphics_qf.is_none() { - graphics_qf = Some(index as u32); - } - if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) - && video_props[index] - .video_codec_operations - .contains(vk::VideoCodecOperationFlagsKHR::DECODE_H264) - && decode_qf.is_none() - { - decode_qf = Some(index as u32); - } - } - if let Some(decode) = decode_qf { - picked = Some((pd, decode, graphics_qf.unwrap_or(decode))); - break; - } - } - let (pd, decode_qf, graphics_qf) = - picked.expect("a physical device with VK_KHR_video_decode_h264 and a decode queue"); - - // Attribution header: which device (and driver) this run actually exercised. + let handles = setup.handles(); { - let mut driver_props = vk::PhysicalDeviceDriverProperties::default(); - let mut props2 = vk::PhysicalDeviceProperties2::default().push_next(&mut driver_props); - // SAFETY: live physical device; the chain fills the Vulkan 1.2 core - // driver-identity struct. - unsafe { instance.get_physical_device_properties2(pd, &mut props2) }; - let props = props2.properties; - eprintln!( - "picked: {:?} vendor=0x{:04x} driver={:?} info={:?}", - props.device_name_as_c_str().unwrap_or(c"?"), - props.vendor_id, - driver_props.driver_name_as_c_str().unwrap_or(c"?"), - driver_props.driver_info_as_c_str().unwrap_or(c"?"), - ); - } - - // ---- logical device: decode (+ graphics) queues, video + sync features ---- - let priorities = [1.0f32]; - let mut queue_infos = vec![vk::DeviceQueueCreateInfo::default() - .queue_family_index(decode_qf) - .queue_priorities(&priorities)]; - if graphics_qf != decode_qf { - queue_infos.push( - vk::DeviceQueueCreateInfo::default() - .queue_family_index(graphics_qf) - .queue_priorities(&priorities), - ); - } - let extensions = [ - ash::khr::video_queue::NAME.as_ptr(), - ash::khr::video_decode_queue::NAME.as_ptr(), - ash::khr::video_decode_h264::NAME.as_ptr(), - ]; - let mut features12 = vk::PhysicalDeviceVulkan12Features::default().timeline_semaphore(true); - let mut features13 = vk::PhysicalDeviceVulkan13Features::default().synchronization2(true); - let device_ci = vk::DeviceCreateInfo::default() - .queue_create_infos(&queue_infos) - .enabled_extension_names(&extensions) - .push_next(&mut features12) - .push_next(&mut features13); - // SAFETY: live physical device, valid create info rooted in locals; destroyed - // at the end of this test after the decoder drops. - let device = - unsafe { instance.create_device(pd, &device_ci, None) }.expect("create the decode device"); - - // ---- the decoder over borrowed handles, exactly as WP-C will hold it ---- - let handles = DeviceHandles { - get_instance_proc_addr: entry.static_fn().get_instance_proc_addr as usize, - instance: instance.handle().as_raw() as usize, - physical_device: pd.as_raw() as usize, - device: device.handle().as_raw() as usize, - decode_qf, - decode_queue_index: 0, - graphics_qf, - }; - { - // SAFETY: the handles above are live for this whole block (the decoder - // drops at its end, before the device/instance destroys below), the device - // was created with the decode extensions + timeline/sync2 features, and - // the queue fields name the families/queues created above. + // SAFETY: `setup` outlives this block (destroyed below, after the decoder + // drops at the block's end), it was created with the H.264 decode + // extensions + timeline/sync2 features, and its queue fields name the + // families/queues it created. let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) } .expect("wrap the device"); - - // 48 AUs — far past the vector's DPB depth (max_dpb_frames = 7), so DPB - // slots re-activate onto fresh pool images repeatedly — with the REAL - // client's consumption shape: the consumer HOLDS four delivered frames - // and releases only the oldest beyond that (its channels + preroll + - // in-flight present hold ~4-7). Status is read (COMPLETE required, the - // program's whole point) as each frame retires; `take_ready` is drained - // every AU so nothing is stranded. No presenter runs here, so releases - // report `presenter_signaled = false` (no `value+1` write-back). - const CLIENT_HOLD: usize = 4; - let aus = split_into_aus(TEST_25FPS); - let mut held: std::collections::VecDeque = - std::collections::VecDeque::new(); - let mut delivered = 0usize; - let mut geometry_checked = false; - for (index, au) in aus.iter().enumerate().take(48) { - let mut next = decoder.decode(au).unwrap_or_else(|e| { - panic!( - "AU {index}: decode failed: {e}\n state: {}", - decoder.debug_snapshot() - ) - }); - while let Some(frame) = next { - if !geometry_checked { - assert_eq!( - (frame.coded_width, frame.coded_height), - (320, 240), - "ALLOCATED extent (320x240 needs no granularity padding here)" - ); - assert_eq!( - (frame.crop.width, frame.crop.height), - (320, 240), - "the vector is uncropped" - ); - assert_ne!(frame.image, vk::Image::null()); - assert_ne!(frame.semaphore, vk::Semaphore::null()); - assert!(frame.value > 0); - geometry_checked = true; - } - held.push_back(frame); - delivered += 1; - // Steady state: keep CLIENT_HOLD frames in hand, retire beyond. - while held.len() > CLIENT_HOLD { - let oldest = held.pop_front().expect("nonempty"); - assert_eq!( - decoder.wait_status(&oldest), - DecodeStatus::Ok, - "AU {index}: decode op not COMPLETE\n state: {}", - decoder.debug_snapshot() - ); - decoder - .release_frame(&oldest, false) - .unwrap_or_else(|e| panic!("AU {index}: release failed: {e}")); - } - next = decoder.take_ready(); - } - } - // Retire the tail the consumer still holds. - for frame in held.drain(..) { - assert_eq!(decoder.wait_status(&frame), DecodeStatus::Ok); - decoder - .release_frame(&frame, false) - .expect("tail frames release"); - } - assert!( - delivered >= 40, - "expected at least 40 delivered frames from 48 AUs, got {delivered}" + smoke( + &mut decoder, + &common::split_h264_aus(common::TEST_25FPS_H264), + &Geometry { + display: (320, 240), + exact_coded: Some((320, 240)), + }, ); - // The DPB mode the caps derivation chose — a passing run should say so - // too (failure paths already carry it via the same snapshot). - eprintln!("final state: {}", decoder.debug_snapshot()); - } - - // ---- teardown (decoder is gone; its Drop drained the queue) ---- - // SAFETY: every object created from the device (the decoder's pools/session) - // was destroyed when the decoder dropped above. - unsafe { - device.destroy_device(None); - instance.destroy_instance(None); } + // SAFETY: the decoder is gone (its Drop drained the queue and destroyed its + // session/pools), and nothing else references the setup's handles. + unsafe { setup.destroy() }; +} + +#[test] +#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"] +fn h265_decodes_48_aus_holding_four_frames_like_the_real_client() { + // One codec at a time on the device (see `common::gpu_lock`). + let _gpu = common::gpu_lock(); + + let setup = common::bring_up(&common::Request { + codec: common::H265, + graphics: common::Graphics::DecodeFamilyIsFine, + report_families: true, + }); + let handles = setup.handles(); + { + // SAFETY: as the H.264 leg — `setup` outlives this block and was created + // with the H.265 decode extensions + timeline/sync2 features. + let mut decoder = unsafe { VkH265Decoder::new(&handles, Box::new(NoopQueueLock)) } + .expect("wrap the device"); + // The construction-time shape gate the client's ladder relies on, on the + // vector's own facts (Main, 4:2:0, 8-bit → NV12). Called here rather than + // left to the first AU so a device that cannot host the combination says + // so as a refusal with a caps reason, not as a mid-stream decode failure — + // and so this path has hardware evidence at all. + decoder + .probe_stream_support(1, 0) + .expect("the box must host H.265 Main 8-bit 4:2:0 (the vector's shape)"); + smoke( + &mut decoder, + &common::split_h265_aus(common::TEST_25FPS_H265), + &Geometry { + display: (320, 240), + // No hardware evidence for HEVC's `pictureAccessGranularity` on + // any fleet box yet; the leg prints what it allocates instead of + // asserting a number nobody has observed. + exact_coded: None, + }, + ); + } + // SAFETY: as the H.264 leg — the decoder is gone and nothing else references + // the setup's handles. + unsafe { setup.destroy() }; +} + +// --------------------------------------------------------------------------- +// CPU coherence guard — NOT `#[ignore]`d. +// +// The legs above only run on the fleet, so [`MIN_DELIVERED`] would otherwise be a +// number copied from the H.264 leg and never checked against the H.265 vector's +// own reorder depth. It is the CPU planner that decides how many of the first +// [`AUS`] pictures can possibly be delivered — the decoder builds exactly one +// frame per `dpb.outputs` id — so the floor is checkable here, without a GPU, and +// a re-synced vector that reorders more deeply fails HERE instead of looking like +// a pool-starvation bug on hardware. +// --------------------------------------------------------------------------- + +#[test] +fn the_delivery_floor_is_under_what_the_planners_emit_from_the_first_48_aus() { + let h264 = { + let mut planner = pf_bitstream::h264::H264Planner::new(); + common::split_h264_aus(common::TEST_25FPS_H264) + .iter() + .take(AUS) + .enumerate() + .map(|(index, au)| { + planner + .plan_au(au) + .unwrap_or_else(|e| panic!("H.264 AU {index} must plan, got {e:?}")) + .dpb + .outputs + .len() + }) + .sum::() + }; + let h265 = { + let mut planner = pf_bitstream::h265::H265Planner::new(); + common::split_h265_aus(common::TEST_25FPS_H265) + .iter() + .take(AUS) + .enumerate() + .map(|(index, au)| { + planner + .plan_au(au) + .unwrap_or_else(|e| panic!("H.265 AU {index} must plan, got {e:?}")) + .dpb + .outputs + .len() + }) + .sum::() + }; + eprintln!("outputs from the first {AUS} AUs: h264={h264} h265={h265}"); + // No `flush` here on purpose: the smoke legs do not flush either, so the + // planner's un-flushed output count is exactly the frame budget they have. + assert!( + h264 >= MIN_DELIVERED, + "the H.264 leg asserts >= {MIN_DELIVERED} delivered but the planner only \ + outputs {h264} pictures from the first {AUS} AUs" + ); + assert!( + h265 >= MIN_DELIVERED, + "the H.265 leg asserts >= {MIN_DELIVERED} delivered but the planner only \ + outputs {h265} pictures from the first {AUS} AUs" + ); }