diff --git a/clients/session/README.md b/clients/session/README.md index 3e7555ca..9bf2c77f 100644 --- a/clients/session/README.md +++ b/clients/session/README.md @@ -69,8 +69,8 @@ Video, then the platform's own (pf-dxvadec on Windows, pf-vaadec on Linux), then rung (openh264/rav1d). The libavcodec rungs that used to sit under each of them are deleted, along with `pf-ffvk` and the `ffmpeg-next` dependency. -Two of the native rungs have never decoded a frame on real hardware (native VAAPI at all; -native D3D11VA's AV1 leg). They run anyway — with the libavcodec twins gone, the only +One of the native rungs has never decoded a frame on real hardware (native VAAPI's H.264 and +H.265 legs; its AV1 leg has decoded but has never been parity-checked). It runs anyway — with the libavcodec twins gone, the only thing below them is the CPU, so barring them would cost the session hardware decode outright rather than move it one rung down. What replaces the safety net is the log: every session names the rung it landed on with its evidence state, diff --git a/crates/pf-bitstream/src/h264.rs b/crates/pf-bitstream/src/h264.rs index 17314b81..146555c9 100644 --- a/crates/pf-bitstream/src/h264.rs +++ b/crates/pf-bitstream/src/h264.rs @@ -240,6 +240,16 @@ pub enum PlanWarning { /// hosts never emit MMCO 5, so this warning is the field signal if that /// assumption ever breaks. Mmco5Rebase, + /// The SPS carried no VUI `bitstream_restriction`, so the DPB had to be sized from + /// A.3.1's LEVEL ceiling — and that ceiling demands more hardware slots than a + /// mainstream decoder provides. See [`dpb_limit`] for why this is the H.264 shape + /// of the defect #96 fixed for HEVC, and why it is a warning here rather than a + /// clamp. Every H.264 encoder a punktfunk host can reach writes the restriction + /// (measured 2026-08-07), so this warning is the field signal if that ever breaks. + LevelDerivedDpb { + max_dpb_frames: usize, + level_idc: u8, + }, } /// The AU cannot be planned at all. @@ -296,12 +306,72 @@ impl From<&Sps> for NegotiationInfo { bit_depth_luma_minus8: sps.bit_depth_luma_minus8, bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8, chroma_format_idc: sps.chroma_format_idc, - max_dpb_frames: sps.max_dpb_frames(), + max_dpb_frames: dpb_limit(sps), interlaced: !sps.frame_mbs_only_flag, } } } +/// The hardware DPB slot count a mainstream decoder provides. NVIDIA's Vulkan Video +/// reports `maxDpbSlots = 16` (RADV 17), and DXVA/D3D11VA's H.264 picture-parameter +/// format indexes the DPB with a 16-entry array. Backends allocate one slot per DPB +/// frame plus one for the picture in flight, so a stream whose DPB is sized at 16 +/// demands 17 and is refused outright — losing the codec, not merely a slower path. +const MAINSTREAM_MAX_DPB_SLOTS: usize = 16; + +/// DPB size in frames — the same question [`crate::h265::dpb_limit`] answers, and the +/// same trap, but H.264 gets a different answer and it is worth writing down why. +/// +/// HEVC's SPS states its own requirement outright (`sps_max_dec_pic_buffering_minus1`), +/// so #96 could simply stop consulting equation A-2's level ceiling. H.264 has no such +/// unconditional field: the stream declares its need ONLY in the VUI's +/// `bitstream_restriction` (`max_dec_frame_buffering`, E.2.1). Absent that, A.3.1's +/// level ceiling — `min(MaxDpbMbs / (PicWidthInMbs * FrameHeightInMbs), 16)` — is all +/// the spec leaves, and it is genuinely the correct inference, not a bug. It is also +/// what the stream MAY use rather than what it needs, which is exactly the shape that +/// killed HEVC at 720p and 1080p. +/// +/// The ceiling saturates at 16 — thus [`MAINSTREAM_MAX_DPB_SLOTS`] + 1 hardware slots, +/// the fatal value — whenever the picture is small relative to its level's `MaxDpbMbs`: +/// +/// | picture | ceiling by level | +/// |---|---| +/// | 720p (3600 MBs) | L3.2 → 5, L4.2 → 9, **L5.0+ → 16** | +/// | 1080p (8160 MBs) | L4.2 → 4, L5.0 → 13, **L5.1+ → 16** | +/// | 1440p (14400 MBs) | L5.1 → 12, **L6.0+ → 16** | +/// | 2160p (32400 MBs) | L5.2 → 5, **L6.0+ → 16** | +/// +/// So H.264 escaped #96 twice over rather than by one piece of luck, and BOTH escapes +/// are properties of the encoders, not of the format. Measured 2026-08-07 by reading +/// the SPS each encoder actually emitted, at 720p/1080p/1440p/2160p: +/// +/// | encoder | level picked | `bitstream_restriction` | `max_dec_frame_buffering` | +/// |---|---|---|---| +/// | NVENC (RTX 5070 Ti, 610.57.04) | 3.2 / 4.2 / 5.1 / 5.2 | present | 3 | +/// | VAAPI via libavcodec (RDNA3, Mesa 26.0.3) | 4.1 / 4.2 / 5.1 / 5.2 | present | 1 | +/// | openh264 (software rung) | 3.2 / 4.2 / 5.1 / 5.2 | present | 1 | +/// +/// Every one of them picks a level proportionate to the picture AND states its real +/// need in the VUI, so the ceiling is never reached and never consulted. That is why +/// this function does NOT clamp: with the restriction present the value below IS the +/// stream's own statement, and clamping a stream that genuinely asked for a deep DPB +/// would corrupt its output. Absent the restriction there is no honest smaller number +/// to substitute — [`PlanWarning::LevelDerivedDpb`] names the situation instead, so the +/// field tells us if a driver ever stops writing the VUI, rather than a user silently +/// losing H.264 the way #96's users silently lost HEVC. +fn dpb_limit(sps: &Sps) -> usize { + // A.3.1's cap, the VUI override and the `max_num_ref_frames` floor all live in the + // vendored parser; this is the one named place the RESULT is interpreted. + sps.max_dpb_frames() +} + +/// Whether [`dpb_limit`] had to fall back to A.3.1's level ceiling because the SPS +/// carried no VUI `bitstream_restriction` — i.e. whether the number is the stream's own +/// statement of need or merely the largest DPB its level permits. +fn dpb_is_level_derived(sps: &Sps) -> bool { + !(sps.vui_parameters_present_flag && sps.vui_parameters.bitstream_restriction_flag) +} + #[derive(Copy, Clone, Debug)] enum RefPicList { RefPicList0, @@ -606,7 +676,7 @@ impl H264Planner { // uncapped. No hardware decoder implements a deeper DPB — a larger value is a // corrupt (or hostile) VUI, not a feature request — and backends size real // slot pools from this number, so it is gated here, at SPS activation. - if sps.max_dpb_frames() > 16 { + if dpb_limit(sps) > MAINSTREAM_MAX_DPB_SLOTS { return Err(PlanError::OutsideEnvelope( "DPB deeper than 16 frames (max_dec_frame_buffering)", )); @@ -1279,10 +1349,19 @@ impl H264Planner { } // Apply the parameters of `sps` to the planning state. - fn apply_sps(&mut self, sps: &Sps) { + fn apply_sps(&mut self, sps: &Sps, warnings: &mut Vec) { self.negotiation_info = NegotiationInfo::from(sps); - let max_dpb_frames = sps.max_dpb_frames(); + let max_dpb_frames = dpb_limit(sps); + // Sized from the level ceiling rather than the stream's own declaration, and + // large enough that backends will ask for more slots than they can get. See + // [`dpb_limit`]: warned, not clamped, because there is no honest smaller number. + if dpb_is_level_derived(sps) && max_dpb_frames + 1 > MAINSTREAM_MAX_DPB_SLOTS { + warnings.push(PlanWarning::LevelDerivedDpb { + max_dpb_frames, + level_idc: sps.level_idc as u8, + }); + } let interlaced = !sps.frame_mbs_only_flag; let max_num_order_frames = sps.max_num_order_frames() as usize; let max_num_reorder_frames = if max_num_order_frames > max_dpb_frames { @@ -1300,13 +1379,17 @@ impl H264Planner { *old_negotiation_info != negotiation_info } - fn renegotiate_if_needed(&mut self, sps: &Sps) -> Result<(), PlanError> { + fn renegotiate_if_needed( + &mut self, + sps: &Sps, + warnings: &mut Vec, + ) -> Result<(), PlanError> { if Self::negotiation_possible(sps, &self.negotiation_info) { Self::check_envelope(sps)?; // Make sure all the frames planned so far are display-ready before the // stream parameters change under them. self.drain_dpb(); - self.apply_sps(sps); + self.apply_sps(sps, warnings); } Ok(()) @@ -1412,7 +1495,7 @@ impl H264Planner { )?); // A picture's SPS may require renegotiation. - self.renegotiate_if_needed(&pps.sps)?; + self.renegotiate_if_needed(&pps.sps, warnings)?; let first_field = self.find_first_field(hdr).map_err(PlanError::Parse)?; @@ -1706,7 +1789,7 @@ impl H264Planner { bit_depth_luma_minus8: sps.bit_depth_luma_minus8, bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8, chroma_format_idc: sps.chroma_format_idc, - max_dpb_frames: sps.max_dpb_frames(), + max_dpb_frames: dpb_limit(sps), recovery_point, } } @@ -1900,6 +1983,21 @@ mod tests { (sps, pps) } + /// The warnings a plan carries, minus the DPB-sizing signal. + /// + /// [`base_sps`]'s fixtures are 64x64 with no VUI bitstream restriction, so A.3.1's + /// ceiling saturates (`MaxDpbMbs(L1) = 396` over 16 macroblocks) and every plan + /// built on them carries [`PlanWarning::LevelDerivedDpb`]. That is the arithmetic + /// `the_level_ceiling_alone_would_reproduce_96_and_is_warned_about` exists to pin — + /// SMALL pictures saturate the ceiling most easily, not large ones — and it says + /// nothing about the picture, which is what the tests below are checking. + fn picture_warnings(plan: &AuPlan) -> Vec<&PlanWarning> { + plan.warnings + .iter() + .filter(|w| !matches!(w, PlanWarning::LevelDerivedDpb { .. })) + .collect() + } + fn param_set_au(sps: &Sps, pps: &Pps) -> Vec { let mut au = Vec::new(); Synthesizer::<'_, Sps, _>::synthesize(3, sps, &mut au, true).unwrap(); @@ -2022,7 +2120,7 @@ mod tests { let p4 = planner.plan_au(&au4).unwrap(); for plan in [&p0, &p1, &p2, &p3, &p4] { assert!( - plan.warnings.is_empty(), + picture_warnings(plan).is_empty(), "authored stream must plan clean: {plan:?}" ); } @@ -2101,7 +2199,10 @@ mod tests { let p1 = planner.plan_au(&au1).unwrap(); let p2 = planner.plan_au(&au2).unwrap(); for plan in [&p0, &p1, &p2] { - assert!(plan.warnings.is_empty(), "must plan clean: {plan:?}"); + assert!( + picture_warnings(plan).is_empty(), + "must plan clean: {plan:?}" + ); } let idr_id = p0.dpb.stored.unwrap(); let lt_id = p1.dpb.stored.unwrap(); @@ -2364,6 +2465,144 @@ mod tests { ); } + /// An SPS at a given picture size and level, with the VUI bitstream restriction + /// either absent (so [`dpb_limit`] must fall back to A.3.1's level ceiling) or + /// present with an explicit `max_dec_frame_buffering`. + fn sps_at(width: u32, height: u32, level: Level, declared: Option) -> Sps { + Sps { + profile_idc: Profile::Main as u8, + level_idc: level, + frame_mbs_only_flag: true, + direct_8x8_inference_flag: true, + // Every H.264 encoder measured below sits at or under this; it is the + // A.3.1 floor `max_dpb_frames` applies, never the value under test. + max_num_ref_frames: 3, + pic_width_in_mbs_minus1: (width / 16 - 1) as u16, + pic_height_in_map_units_minus1: (height / 16 - 1) as u16, + vui_parameters_present_flag: declared.is_some(), + vui_parameters: VuiParams { + bitstream_restriction_flag: declared.is_some(), + max_dec_frame_buffering: declared.unwrap_or(0), + ..Default::default() + }, + ..Default::default() + } + } + + /// The H.264 half of `pf-encode`'s `rfi_dpb_fits_a_mainstream_vulkan_decoder`. + /// + /// That test guards the PRODUCER end for HEVC — that `RFI_DPB` never grows past + /// what a client can allocate. This is the CONSUMER end for H.264, where the + /// number is not ours to choose: the client derives it from whatever SPS arrives, + /// and #96 proved that deriving a DPB from a level's ceiling instead of a stream's + /// need costs the codec outright. + /// + /// Pinned here are the (picture, level) pairs a punktfunk host can actually reach, + /// with the level each shipping encoder was MEASURED to pick on 2026-08-07 (see + /// [`dpb_limit`] for the full table and the hardware). The property that has to + /// hold is the one the backends enforce: `dpb_limit + 1 <= 16` slots. + #[test] + fn every_reachable_h264_stream_fits_a_mainstream_slot_pool() { + // (picture, level measured on NVENC / VAAPI / openh264, their VUI declaration) + let measured = [ + ((1280, 720), Level::L3_2, 3), // NVENC, openh264 + ((1280, 720), Level::L4_1, 1), // VAAPI via libavcodec + ((1920, 1080), Level::L4_2, 3), // all three + ((2560, 1440), Level::L5_1, 3), + ((3840, 2160), Level::L5_2, 3), + ]; + for ((w, h), level, declared) in measured { + let sps = sps_at(w, h, level, Some(declared)); + assert!( + !dpb_is_level_derived(&sps), + "{w}x{h} L{:?}: the measured encoders all write the VUI restriction — \ + an SPS that carries it must never be treated as level-derived", + level + ); + let slots = dpb_limit(&sps) + 1; + assert!( + slots <= MAINSTREAM_MAX_DPB_SLOTS, + "{w}x{h} L{level:?} declaring {declared} needs {slots} DPB slots, \ + mainstream hardware caps at {MAINSTREAM_MAX_DPB_SLOTS}" + ); + } + } + + /// The cliff the measured encoders walk past, pinned so it stays visible. + /// + /// These are the SAME resolutions, at levels a host could legally pick, with no + /// VUI restriction to state the real need. Each computes a 16-frame DPB — 17 + /// hardware slots — which is precisely the arithmetic that killed HEVC at 720p and + /// 1080p before #96. H.264 escapes it because every encoder both picks a + /// proportionate level AND writes the restriction, not because the format is safe. + #[test] + fn the_level_ceiling_alone_would_reproduce_96_and_is_warned_about() { + // (picture, a level that saturates A.3.1's ceiling for it) + let cliff = [ + ((1280, 720), Level::L5), + ((1280, 720), Level::L5_2), + ((1920, 1080), Level::L5_1), + ((2560, 1440), Level::L6), + ((3840, 2160), Level::L6_2), + ]; + for ((w, h), level) in cliff { + let sps = sps_at(w, h, level, None); + assert!(dpb_is_level_derived(&sps), "{w}x{h} L{level:?}"); + assert_eq!( + dpb_limit(&sps), + 16, + "{w}x{h} L{level:?} should saturate A.3.1's 16-frame ceiling" + ); + assert!( + dpb_limit(&sps) + 1 > MAINSTREAM_MAX_DPB_SLOTS, + "{w}x{h} L{level:?} is the #96 arithmetic and must be recognised as such" + ); + + // ...and the planner must NAME it rather than let a user silently lose the + // codec. The SPS activates on the first slice, so the warning rides out on + // the IDR's plan. + let sps = Rc::new(sps); + let pps = PpsBuilder::new(Rc::clone(&sps)) + .pic_parameter_set_id(0) + .pic_init_qp(26) + .build(); + let mut au = param_set_au(&sps, &pps); + au.extend(write_idr_slice()); + + let plan = H264Planner::new() + .plan_au(&au) + .unwrap_or_else(|e| panic!("{w}x{h} L{level:?} should plan, got {e:?}")); + assert!( + plan.warnings.contains(&PlanWarning::LevelDerivedDpb { + max_dpb_frames: 16, + level_idc: level as u8, + }), + "{w}x{h} L{level:?}: expected LevelDerivedDpb, got {:?}", + plan.warnings + ); + } + } + + /// A proportionate level is the other half of the escape: at the levels the + /// encoders actually pick, the ceiling is small enough that even a stream with no + /// VUI at all fits — so neither escape is doing all the work alone. + #[test] + fn a_proportionate_level_fits_even_without_a_vui_restriction() { + let proportionate = [ + ((1280, 720), Level::L3_2, 5), + ((1280, 720), Level::L4_1, 9), + ((1920, 1080), Level::L4_2, 4), + ((2560, 1440), Level::L5_1, 12), + ((3840, 2160), Level::L5_2, 5), + ]; + for ((w, h), level, expected) in proportionate { + let sps = sps_at(w, h, level, None); + assert_eq!(dpb_limit(&sps), expected, "{w}x{h} L{level:?}"); + let slots = dpb_limit(&sps) + 1; // + the picture in flight + assert!(slots <= MAINSTREAM_MAX_DPB_SLOTS, "{w}x{h} L{level:?}"); + } + } + /// MMCO 5 writer: op 5 takes NO argument (Table 7-9), so the generic /// [`write_p_slice`] — whose supported ops all take exactly one — cannot author /// it. @@ -2688,11 +2927,11 @@ mod tests { let plan = planner.plan_au(&write_idr_slice()).unwrap(); assert!(plan.picture.is_idr); assert_eq!(plan.picture.pic_order_cnt, 0); - assert!(plan.warnings.is_empty()); + assert!(picture_warnings(&plan).is_empty()); // And the stream continues cleanly on the reset state. let plan = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap(); - assert!(plan.warnings.is_empty()); + assert!(picture_warnings(&plan).is_empty()); assert_eq!(plan.slices[0].ref_list0.len(), 1); } @@ -2725,7 +2964,7 @@ mod tests { au.extend(write_idr_slice_at(8, 1)); let plan = H264Planner::new().plan_au(&au).unwrap(); - assert!(plan.warnings.is_empty()); + assert!(picture_warnings(&plan).is_empty()); assert_eq!(plan.slices.len(), 2); // The picture parameters come from the FIRST slice's PPS (the uncropped // SPS 0); they must not drift to the last slice's. diff --git a/crates/pf-client-core/Cargo.toml b/crates/pf-client-core/Cargo.toml index d44fe6b3..1c772a38 100644 --- a/crates/pf-client-core/Cargo.toml +++ b/crates/pf-client-core/Cargo.toml @@ -165,11 +165,16 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410 "winuser", ] } -[target.'cfg(windows)'.dev-dependencies] -# The native D3D11VA rung's frame-hash parity test compares decoded surfaces against the -# libavcodec goldens M5 captured — the same SHA-256 list, and the same crate, pf-vkdecode's -# Vulkan parity legs use (already in the workspace lock). The goldens are checked-in -# hashes; nothing links FFmpeg to read them. +[target.'cfg(any(target_os = "linux", windows))'.dev-dependencies] +# The two platform native rungs' frame-hash parity tests compare decoded surfaces against +# the libavcodec goldens M5 captured — the same SHA-256 list, and the same crate, +# pf-vkdecode's Vulkan parity legs use (already in the workspace lock). The goldens are +# checked-in hashes; nothing links FFmpeg to read them. +# +# Windows was the only platform here until the VAAPI rung grew a readback: `cfg(windows)` +# for `video_d3d11_native::parity`, now `cfg(linux)` as well for +# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything — +# which is also part of why the VAAPI readback cannot reach the production video path. sha2 = "0.10" [features] diff --git a/crates/pf-client-core/src/lib.rs b/crates/pf-client-core/src/lib.rs index 2926c6c0..3acbb5be 100644 --- a/crates/pf-client-core/src/lib.rs +++ b/crates/pf-client-core/src/lib.rs @@ -105,8 +105,8 @@ pub mod clipboard; pub mod video_d3d11; // Native D3D11VA (M5): `ID3D11VideoDecoder` driven from pf-bitstream plans, filling the // hand-off ring `video_d3d11` owns. Since M10 it is the only DXVA rung there is. In `auto` -// for the codecs that have hardware evidence (H.264/H.265) and, with nothing proven left -// below it, for AV1 too — see `video`'s evidence table; `PUNKTFUNK_DECODER=native-d3d11va` +// for all three codecs, each of which now has hardware evidence — H.264/H.265 since M5, AV1 +// since 2026-08-07 — see `video`'s evidence table; `PUNKTFUNK_DECODER=native-d3d11va` // reaches every leg by pin. #[cfg(windows)] pub mod video_d3d11_native; diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index f29547e7..8326fe18 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -16,15 +16,16 @@ //! M9's evidence FILTER survives, narrowed to the one thing it can still protect //! ([`native_rung_admitted`]). The filter kept a rung that had never decoded on real //! hardware out of `auto` while its proven libavcodec twin was one step below. With the -//! twins deleted that is usually no longer the situation: below native-d3d11va's AV1 leg, -//! and below native-vaapi on NVIDIA/AMD, there is nothing proven left to fall onto, so -//! barring the rung would not move a session one rung DOWN — it would take hardware decode -//! away from it entirely, which is the worse answer. +//! twins deleted that is usually no longer the situation: below native-vaapi on NVIDIA/AMD +//! there is nothing proven left to fall onto, so barring the rung would not move a session +//! one rung DOWN — it would take hardware decode away from it entirely, which is the worse +//! answer. (native-d3d11va's AV1 leg was the other standing example until 2026-08-07, when +//! it earned parity on two vendors and stopped being an unproven rung at all.) //! //! **One column of that table is different, and it is the one the filter still guards.** //! On Linux, Intel and every unknown vendor id run `native-vaapi → native-vk → sw` //! ([`VulkanDecodeDevice::prefer_vulkan_first`] is true for NVIDIA and AMD only), so the -//! rung directly below the never-run pf-vaadec is native Vulkan Video — H.264 and H.265 on +//! rung directly below the unproven pf-vaadec is native Vulkan Video — H.264 and H.265 on //! three drivers plus a 92-minute soak, AV1 250/250. There, barring the unproven rung moves //! the session exactly one rung down, onto proven code, so it is barred: an unproven rung //! yields to a rung that is BOTH verified for this codec and usable on THIS device, and to @@ -48,9 +49,9 @@ //! | native Vulkan Video | [`crate::video_vk_native`] | H.264 | **yes** — bit-exact vs libavcodec, 250/250 AUs on three drivers + a 92-minute soak (M2 WP-D) | //! | native Vulkan Video | | H.265 (Main / Main10 / 4:4:4) | **yes** — same parity run + HDR chain and Deck/VanGogh legs (M3) | //! | native Vulkan Video | | AV1 | **yes** — 250/250 bit-identical to libavcodec on an RTX 5070 Ti (M7); ONE vendor, no soak | -//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5) | -//! | native D3D11VA | | AV1 | **not proven** — it HAS now decoded (4K60, RTX 3500 Ada, 2026-08-07), but with no parity check and no soak it stays out of the admission filter. Its M7 wiring was right all along: what looked like a DXVA reference-mapping bug (`reference picture N holds no DPB slot`, 72 consecutive failures) was the HOST shipping half of every AV1 frame — see `pf_encode`'s `resolve_split_subframe` | -//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265, AV1 | **NO** — has never decoded a frame anywhere (M6/M7; no VAAPI hardware was reachable) | +//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5), re-confirmed 250/250 (+ 50/50 Main 10) on an RTX 3500 Ada and an Intel Arc on 2026-08-07 | +//! | native D3D11VA | | AV1 | **yes** — 250/250 delivered frames bit-identical to libavcodec on an RTX 3500 Ada AND an Intel Arc (2026-08-07). It got there from 186/250 and 245/250 DIVERGING frames on those same two GPUs: `plan_to_dxva_av1` released the picture this frame's own refresh displaces before assigning the decode target its slot, and `SlotMap::assign` hands back the slot just vacated — so 268 of the vector's 274 frames named one surface as both `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry. Intel followed the aliased surface (structurally wrong from display frame 4); NVIDIA tolerated it until the `order_hint` wrap at 64 made one 16x24 luma block depend on it. ONE defect, two driver tolerances — the two unlike signatures were not two bugs. TWO vendors, still NO soak on the goldens: the 5-minute 4K60 soak this row used to cite measured throughput, and "streams cleanly" was true throughout the failure | +//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265 (Main / Main 10), AV1 | **frame-hash parity-proven, ON ONE VENDOR** — 7 legs bit-identical to libavcodec on `.25` (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23) on 2026-08-08: vendored H.264 250/250, our host's low-delay H.264 120/120, vendored H.265 250/250, host low-delay H.265 120/120, HEVC Main 10 50/50 (P010), vendored AV1 250/250 of 274 decoded, host low-delay 4K two-tile AV1 60/60. The readback that made it possible is `vaDeriveImage` with a `vaCreateImage`+`vaGetImage` fallback, and it is `#[cfg(test)]`-only by construction — the production [`video_vaapi_native::Libva`] gains no entry point and a CPU test scans this crate's own source to keep it that way. **Not `verified`, and the reason is no longer parity**: one vendor and no soak. Flipping it moves `auto` on Linux AMD/Intel from Vulkan Video to VAAPI ([`native_rung_admitted`]) — an evidence-backed change, but a routing change, so it is made on purpose or not at all | //! | software | `video_software` | H.264, AV1 | **not proven** — openh264 has never run on glass; rav1d HAS now decoded 1080p and 4K60 AV1 there (2026-08-07, .21) and recovers in-session from a mid-stream reference loss, but with no parity check and no soak. Its 4K "abort" was never about 4K: rav1d 1.1.0 kills the process on ANY decode error while it holds a single frame context, so `video_software` opens it with two — see [`crate::video_software`] | //! //! The software rung's evidence is recorded for the same reason but does not gate @@ -240,8 +241,9 @@ pub struct DecodeHealth { pub worst_run: u32, /// Frames that decoded CORRECTLY and were then discarded without ever being /// shown, because the backend's deliverable queue overflowed - /// (`video_vk_native::MAX_DELIVERABLE` — a decoder making more pictures - /// display-ready per access unit than the pump can take one at a time). + /// (`video_vk_native::MAX_DELIVERABLE` and `video_vaapi_native::max_deliverable` + /// — a decoder making more pictures display-ready per access unit than the pump + /// can take one at a time). /// /// Deliberately its own number and not folded into any of the three above: /// nothing was damaged, nothing was refused and no driver failed, so counting @@ -251,8 +253,8 @@ pub struct DecodeHealth { /// discarding a frame per AU is one running at half the frame rate it thinks /// it is, and before this counter existed it read as perfectly clean. /// - /// Structurally 0 on every rung but native Vulkan — it is the only one with a - /// deliverable queue — and not on the session stats line today; the + /// Structurally 0 on every rung but native Vulkan and native VAAPI — the two with + /// a deliverable queue — and not on the session stats line today; the /// rate-limited `warn` at the drop site is the field signal, and this is the /// number a stats field would read. pub dropped: u64, @@ -771,7 +773,10 @@ enum Backend { /// libavcodec's VAAPI hwaccel, and since M10 the only VAAPI rung: libva driven /// straight from pf-bitstream plans, dlopen'd, exporting the same DRM-PRIME dmabufs. /// Reachable by pin (`PUNKTFUNK_DECODER=native-vaapi`) and by `auto` in the vendor - /// order. ⚠ This rung has decoded NOTHING on hardware ([`native_evidence`]) — `auto` + /// order. ⚠ All four legs have now decoded on RDNA3 — 250/250/50/250 for + /// H.264/H.265/HEVC Main 10/AV1 — but every one of them is unverified for want of + /// frame-hash parity, which the tiled dmabuf makes impossible without a readback path + /// this rung does not have ([`native_evidence`]) — so `auto` /// runs it where the alternative below it is the CPU, and yields to native Vulkan /// Video where that rung is proven for the codec and usable on the device /// ([`native_rung_admitted`], which is the Intel/unknown arm). Every session that @@ -786,8 +791,9 @@ enum Backend { /// `ID3D11VideoDecoder` driven from pf-bitstream plans, filling the shareable-RGBA /// hand-off ring in `crate::video_d3d11`. /// Reachable by pin (`PUNKTFUNK_DECODER=native-d3d11va`) and by `auto` in the vendor - /// order: its H.264/H.265 legs have hardware parity + a soak (M5); its AV1 leg has - /// decoded nothing anywhere and runs with the warning [`log_rung`] emits. Errors + /// order: its H.264/H.265 legs have hardware parity + a soak (M5), and its AV1 leg has + /// frame-hash parity on two vendors since 2026-08-07 (M7) — 250/250 after a decode + /// target that aliased a reference surface was fixed. Errors /// ride the SAME streak/demotion machinery as every other hardware rung. /// Boxed: the decoder (two planners plus a session) dwarfs the other variants. #[cfg(windows)] @@ -1109,20 +1115,68 @@ pub fn native_evidence(rung: NativeRung, wire: u8) -> RungEvidence { true, "frame-hash parity on an RTX 4090 and an AMD iGPU + 30-min soak (M5)", ), - // Decoded on hardware for the first time on 2026-08-07 (4K60, RTX 3500 Ada) once the - // host stopped truncating AV1 — so the old "NEVER decoded a frame anywhere" is no - // longer true and must not be printed. Still NOT `verified`: `verified` gates - // `native_rung_admitted`, i.e. whether `auto` may pick this rung AHEAD of Vulkan - // Video, and one 25-second session with no frame-hash parity and no soak does not - // buy that. Promoting it wants a `gpu_parity`-style run, deliberately. + // 2026-08-07: this pair failed its first parity check and now PASSES it, on both of + // the box's GPUs, after one defect was fixed. + // + // The failure was 186/250 diverging display frames on an RTX 3500 Ada and 245/250 on + // an Intel Arc, deterministic on three runs each. The two signatures looked like two + // defects — NVIDIA bit-exact through display frame 63 and then one 16x24 luma block + // (max |delta| 8, chroma untouched) at the frame whose `order_hint` first reaches 64; + // Intel structurally wrong from display frame 4 (47% of luma, max |delta| 242, chroma + // wrong too) with only its one PRIMARY_REF_NONE frame right. They were ONE defect and + // two driver tolerances. + // + // `plan_to_dxva_av1` released the pictures this frame's own `refresh_frame_flags` + // displaces INSIDE the conversion, then assigned the decode target a slot — and + // `SlotMap::assign` takes the lowest free slot, which is the one just vacated. So the + // submission named the same surface as `CurrPicTextureIndex` and as a + // `RefFrameMapTextureIndex` entry, on 268 of the vector's 274 frames: decode into the + // surface you are predicting from. AV1 applies `refresh_frame_flags` AFTER the frame + // is decoded (7.20), so that shape is ordinary rather than exotic; neither vendored + // H.264 nor H.265 vector ever produces it, which is why an eager release survived two + // hardware-proven codecs. Intel followed the aliased surface, NVIDIA tolerated it + // until the order-hint wrap made one block's prediction depend on it. `pf_dxvadec`'s + // `the_decode_target_never_aliases_a_surface_the_submission_names` is the CPU guard. + // + // ⚠ What this pair still does NOT have, unlike the H.264/HEVC one above: a soak on + // the goldens. The 5-minute 4K60 soak this note used to lean on measured throughput, + // not pixels, and "streams cleanly" is exactly what was true while 186 frames were + // wrong. (NativeRung::D3d11va, CODEC_AV1) => ( - false, - "decoded 4K60 once on an RTX 3500 Ada (2026-08-07) but has NEVER been \ - parity-checked or soaked (M7)", + true, + "250/250 delivered frames bit-identical to libavcodec on an RTX 3500 Ada AND an \ + Intel Arc (2026-08-07), after fixing a decode target that aliased a reference \ + surface on 268 of 274 frames - two vendors, no soak (M7)", ), + // 2026-08-07: the VAAPI rung decoded its first frames ever, and by the end of that + // day ALL FOUR legs had — 250/250 of the vendored AV1 vector, then every access unit + // of the H.264 (250), H.265 (250) and HEVC Main 10 (50) vectors, on `.25` (Radeon + // 780M, RDNA3, Mesa 26.0.3): NV12 for the 8-bit legs, P010 for Main 10, all on a + // tiled AMD modifier. So "never decoded a frame anywhere" is no longer true of ANY of + // them and must not be printed. The arm stays split only because AV1's note carries + // its own frame count; both halves say the same thing about parity. + // + // 2026-08-08: parity finally exists here. A `#[cfg(test)]` readback + // (`vaDeriveImage`, falling back to `vaCreateImage` + `vaGetImage`) hashes the + // decoded surface, and all SEVEN legs came back bit-identical to libavcodec — the + // conformance vectors and our own host's low-delay streams, H.264 through AV1. + // + // So the old reason for `false` is gone, and the arm is no longer split: every leg + // has the same evidence. What is left is narrower and worth stating exactly, because + // the D3D11VA AV1 row above is a rung that decoded 250 frames and produced wrong + // pixels for every one of them — parity is what separated them, and this rung now + // has it. It stays `false` on ONE VENDOR and NO SOAK, and because flipping it is a + // routing change, not a bookkeeping one: `native_rung_admitted` would then let `auto` + // pick VAAPI ahead of Vulkan Video on every Linux AMD and Intel client, the Steam + // Deck included. That is defensible on this evidence and should be done deliberately, + // not as a side effect of recording a parity result. (NativeRung::Vaapi, _) => ( false, - "NEVER decoded a frame on any hardware - no VAAPI device was reachable (M6/M7)", + "7 legs bit-identical to libavcodec on RDNA3 (Mesa 26.0.3, 2026-08-08) - H.264, \ + H.265, HEVC Main 10 and AV1, on both the conformance vectors and our own host's \ + low-delay streams - but has NEVER run on a second vendor and has never been \ + soaked, and `verified` here would move `auto` off Vulkan Video on every Linux \ + AMD/Intel client (M6/M7)", ), // The 4K AV1 abort recorded here on 2026-08-07 is FIXED, and it was never about 4K. // rav1d 1.1.0 aborts the process on ANY decode error while it holds a single frame @@ -1174,17 +1228,20 @@ pub fn native_vulkan_usable(wire: u8, video_decode: bool, decode_video_caps: u32 /// Where the rule bites, and where it deliberately does not: /// /// * **Linux, Intel and every unknown vendor id.** The order is `native-vaapi → -/// native-vk → sw`, so the rung under the never-run pf-vaadec is native Vulkan Video, +/// native-vk → sw`, so the rung under the unproven pf-vaadec is native Vulkan Video, /// proven for all three codecs. Barring VAAPI there moves the session ONE rung down onto /// proven code, so it is barred — and it stays reachable below Vulkan (the same ladder /// reaches it again if Vulkan can't be built) and by pin. /// * **Everything else.** Below the unproven rung is the CPU. Trading hardware decode for /// software decode to avoid an unproven decoder is the worse answer, so those rungs run, -/// with the warning [`log_rung`] emits. That includes Windows Intel/unknown, where the +/// with the warning [`log_rung`] emits. That included Windows Intel/unknown, where the /// rung below native-d3d11va IS native Vulkan Video on paper: that vendor family is the /// one thing in this program with a MEASURED wrong-pixel report against Vulkan decode /// (the B580, see [`Decoder::new`]), and "has never run" is not a reason to move a -/// session onto "known to strobe here". Callers say so where they pass `None`. +/// session onto "known to strobe here". Callers say so where they pass `None`. ⚠ Since +/// 2026-08-07 no D3D11VA leg is unproven, so that arm no longer exercises this clause — +/// the reasoning is kept because the `None` those callers pass is still what decides the +/// answer if any leg's evidence ever goes bad again. /// /// ⚠ This governs `auto` ONLY. An explicit `PUNKTFUNK_DECODER=` pin bypasses it exactly as /// it bypasses the vendor order — a pin is how a lab run reaches a rung `auto` will not @@ -1444,8 +1501,13 @@ pub fn decodable_codecs() -> u8 { /// * the presenter's Vulkan device advertises `DECODE_AV1` in its decode queue /// family's codec operations, or /// * (Windows) the presenter can import D3D11 textures — the native DXVA rung then decodes -/// AV1 Profile 0 through the adapter's profile GUID, and `auto` reaches it. ⚠ That leg -/// has decoded nothing on hardware ([`native_evidence`]); the session says so at `warn`. +/// AV1 Profile 0 through the adapter's profile GUID, and `auto` reaches it. That leg is +/// frame-hash parity-proven on two vendors since 2026-08-07 ([`native_evidence`]); until +/// that run it was measured decoding WRONG PIXELS on both, and advertising AV1 was still +/// answered from device facts here — deliberately, because withdrawing the codec would be +/// a product call about what a Windows Intel box streams instead, not a fact about the +/// device. That reasoning is kept rather than deleted: it is the shape this arm has to +/// hold the next time a leg's evidence goes bad. /// Before M10 this arm was conditional, because the leg was kept out of `auto` while /// libavcodec's DXVA rung was still below it — with that rung deleted there is no /// condition left to write. @@ -1550,8 +1612,9 @@ fn report_au_fault_env(native_rung: bool) { /// a frame through it for this codec. /// /// This is the program's honesty surface, and M10 is where it earns its keep: every rung -/// is now native, two of them have never decoded anything anywhere, and there is no -/// libavcodec twin left underneath to catch a session that lands wrong. A field report of +/// is now native, one of them still has legs that have never decoded anything anywhere, one +/// leg that was measured decoding wrong pixels has since been fixed and proven, and there is +/// no libavcodec twin left underneath to catch a session that lands wrong. A field report of /// the form "M10 broke my stream" is only actionable if the log distinguishes *the rung /// with three drivers and a 92-minute soak behind it* from *the rung nothing has ever /// run*, and the `stats:` decode-path tag — which is a machine interface and stays @@ -1628,10 +1691,10 @@ impl Decoder { /// Intel/unknown (Intel's driver advertises Vulkan Video, but Vulkan decode on it /// strobed/overran the budget — B580 field report). /// - /// On top of that order sits the evidence filter ([`native_rung_admitted`]): a rung - /// that has never decoded a frame does not go FIRST when the rung directly below it is - /// proven for this codec and usable on this device. That is the Linux Intel/unknown - /// arm and only that arm — everywhere else what is below is the CPU. + /// On top of that order sits the evidence filter ([`native_rung_admitted`]): an + /// UNPROVEN rung does not go FIRST when the rung directly below it is proven for this + /// codec and usable on this device. That is the Linux Intel/unknown arm and only that + /// arm — everywhere else what is below is the CPU. /// /// Whatever it lands on, the session logs `decode rung active` with the rung's name /// and its evidence state, and that line is a WARNING when no hardware has ever @@ -1918,8 +1981,9 @@ impl Decoder { // // Windows' D3D11VA RUNG: native D3D11VA (pf-dxvadec). Its H.264/H.265 legs HAVE // hardware evidence (parity on an RTX 4090 and an AMD iGPU plus a 30-minute soak, - // M5); its AV1 leg has none and runs with the warning `done` logs — until M10 that - // leg was skipped in `auto` in favour of libavcodec's DXVA rung, which no longer + // M5), and since 2026-08-07 so does its AV1 leg (250/250 on an RTX 3500 Ada and an + // Intel Arc, no soak) — until M10 that leg was skipped in `auto` in favour of + // libavcodec's DXVA rung, which no longer // exists. The rung needs the presenter's win32 import path or its frames could // never reach the screen — that check is first, once. #[cfg(windows)] @@ -1976,6 +2040,19 @@ impl Decoder { // so what is really below the DXVA AV1 leg is the CPU — and its H.264/H.265 // legs are verified anyway, which is what the first clause of // [`native_rung_admitted`] answers. + // + // CLOSED, 2026-08-07 (opened and closed the same day): for a few hours this + // admitted a rung MEASURED to decode wrong pixels — 186/250 and 245/250 + // diverging frames — and on Intel it is the arm that actually fires, because + // that vendor advertises no SAMPLED usage on any decode profile so zero-copy + // Vulkan Video cannot run there. The question it raised is worth keeping even + // though the answer expired: the filter asks "has this rung any evidence", and + // a rung with BAD evidence is a case the rule was never posed. It was left + // admitted rather than barred, because barring it trades visibly-wrong AV1 for + // the software rung, which cannot keep up at 4K (see [`crate::video_software`]) + // and is itself unproven. The AV1 leg is now parity-proven on both vendors + // ([`native_evidence`]) so this arm admits a PROVEN rung today, and + // `native_rung_admitted`'s first clause would admit it whatever were below. && native_rung_admitted(NativeRung::D3d11va, wire, None) { d3d11_tried = true; @@ -3234,8 +3311,8 @@ mod tests { /// /// This test is the reason the table can be trusted a milestone from now. M9 turned /// native rungs on by default and M10 deleted every libavcodec rung beneath them; the - /// argument for doing that honestly rests entirely on the claim "these five pairs are - /// proven and these six are not", and on the session log saying so. A + /// argument for doing that honestly rests entirely on the claim "these six pairs are + /// proven and these five are not", and on the session log saying so. A /// table nobody checks drifts into a table that says everything is fine — which is /// the exact failure this whole program exists to end, one layer up. #[test] @@ -3254,6 +3331,11 @@ mod tests { ), (NativeRung::D3d11va, CODEC_H264, "native D3D11VA H.264 (M5)"), (NativeRung::D3d11va, CODEC_HEVC, "native D3D11VA H.265 (M5)"), + ( + NativeRung::D3d11va, + CODEC_AV1, + "native D3D11VA AV1 (M7, RTX 3500 Ada + Intel Arc, 2026-08-07)", + ), ] { assert!( native_evidence(rung, codec).verified, @@ -3261,25 +3343,20 @@ mod tests { ); } for (rung, codec, why) in [ - ( - NativeRung::D3d11va, - CODEC_AV1, - "the DXVA AV1 leg never ran (M7)", - ), ( NativeRung::Vaapi, CODEC_H264, - "no VAAPI device was reachable", + "no VAAPI device has run this leg", ), ( NativeRung::Vaapi, CODEC_HEVC, - "no VAAPI device was reachable", + "no VAAPI device has run this leg", ), ( NativeRung::Vaapi, CODEC_AV1, - "no VAAPI device was reachable", + "VAAPI decoded AV1 on RDNA3 but has no parity check", ), ( NativeRung::Software, @@ -3330,20 +3407,20 @@ mod tests { /// read against. Which of them `auto` may pick FIRST is /// [`native_rung_admitted`]'s decision, asserted in the test after this one. /// - /// `(D3d11va, AV1)` stays here after 2026-08-07 even though it has now decoded on - /// hardware, and its warn line is why: it named the rung as unproven moments before that - /// rung failed 72 access units running, which is exactly the job this test protects. (The - /// cause was the HOST shipping half of every AV1 frame — `pf_encode`'s - /// `resolve_split_subframe` — not the rung.) Unproven is about EVIDENCE, not about whether - /// it has ever worked: one 25-second session with no parity check and no soak must not - /// promote a rung past Vulkan Video in the admission filter. + /// `(D3d11va, AV1)` LEFT this list on 2026-08-07, and the bar it had to clear is the + /// point. It had already decoded on hardware twice over — a 25-second session and a + /// 5-minute 4K60 soak on two GPUs — and it stayed unproven through both, because + /// unproven is about EVIDENCE and neither run looked at a pixel. What moved it was the + /// frame-hash parity check: 250/250 delivered frames bit-identical to libavcodec on an + /// RTX 3500 Ada AND an Intel Arc. Its first run of that check FAILED on both (186/250 and + /// 245/250 diverging), while the rung streamed 4K60 the whole time — so the warn line + /// this test protects was telling the truth right up to the run that retired it. #[test] fn every_rung_runs_and_the_unproven_ones_are_named() { let unproven = [ (NativeRung::Vaapi, CODEC_H264), (NativeRung::Vaapi, CODEC_HEVC), (NativeRung::Vaapi, CODEC_AV1), - (NativeRung::D3d11va, CODEC_AV1), ]; for (rung, codec) in unproven { let e = native_evidence(rung, codec); @@ -3356,7 +3433,9 @@ mod tests { assert!( e.note.contains("NEVER") || e.note.contains("never"), "{} / {codec:#x}: the note is what the session log prints at warn — it \ - must say plainly that nothing has run it, got {:?}", + must name plainly what this pair has NEVER had, whether that is a \ + hardware run at all (VAAPI H.264/H.265) or the parity check that would \ + promote it (VAAPI AV1, which HAS decoded), got {:?}", rung.name(), e.note ); @@ -3369,6 +3448,7 @@ mod tests { (NativeRung::Vulkan, CODEC_AV1), (NativeRung::D3d11va, CODEC_H264), (NativeRung::D3d11va, CODEC_HEVC), + (NativeRung::D3d11va, CODEC_AV1), ] { assert!(native_evidence(rung, codec).verified, "{}", rung.name()); } @@ -3421,6 +3501,15 @@ mod tests { (NativeRung::Vulkan, CODEC_AV1), (NativeRung::D3d11va, CODEC_H264), (NativeRung::D3d11va, CODEC_HEVC), + // Joined this list on 2026-08-07 with 250/250 on two vendors. ⚠ It is a + // BEHAVIOUR change on real machines and not only a label: this pair was the + // one the filter barred under `Some(Vulkan)`, so on Windows Intel and + // unknown-vendor boxes — where the ladder is `native-d3d11va → native-vk → sw` + // — `auto` now decodes AV1 on D3D11VA where it previously fell to Vulkan + // Video. Taken deliberately: the rung is ~10x the Vulkan leg's speed, and the + // parity that promoted it was measured on an Intel Arc, which is exactly the + // vendor family that change moves. + (NativeRung::D3d11va, CODEC_AV1), ] { for below in [ None, @@ -3435,17 +3524,6 @@ mod tests { ); } } - // Windows, Intel/unknown auto: the DXVA AV1 leg has never run, and the ladder - // passes `None` there on purpose — that vendor family is the one with a measured - // wrong-pixel report against Vulkan decode, so what is really below it is the CPU. - // This asserts the ARGUMENT the call site passes, which is where the judgement - // lives; `Some(Vulkan)` would bar it, and that is deliberately not what it passes. - assert!(native_rung_admitted(NativeRung::D3d11va, CODEC_AV1, None)); - assert!(!native_rung_admitted( - NativeRung::D3d11va, - CODEC_AV1, - Some(NativeRung::Vulkan) - )); // The CPU rung is last everywhere, so nothing is ever below it and it always runs // — including for a codec it has no decoder for, which is `last_rung_verdict`'s // problem and not the filter's. diff --git a/crates/pf-client-core/src/video_d3d11_native.rs b/crates/pf-client-core/src/video_d3d11_native.rs index 04ad598e..408d7990 100644 --- a/crates/pf-client-core/src/video_d3d11_native.rs +++ b/crates/pf-client-core/src/video_d3d11_native.rs @@ -18,10 +18,58 @@ //! (`video::native_evidence`, and the table in `video`'s module docs): //! //! * **H.264 and H.265** — frame-hash parity against libavcodec on an RTX 4090 and an AMD -//! iGPU plus a 30-minute soak (M5). -//! * **AV1** — wired in M7, has decoded nothing on any hardware. Until M10 `auto` skipped it -//! in favour of the libavcodec rung below; with that gone the alternative is the CPU, so it -//! runs and the session log says so at `warn`. +//! iGPU plus a 30-minute soak (M5), re-confirmed on an RTX 3500 Ada and an Intel Arc on +//! 2026-08-07 (250/250 both codecs, plus 50/50 HEVC Main 10 on both). +//! +//! ⚠⚠ **All of that was against ONE vendored vector per codec, and for H.264 the vector +//! was blind to a defect present on 99% of the frames we actually stream.** It reorders +//! and carries a 7-frame DPB against 2 reference frames; a punktfunk host emits +//! low-delay IPPP whose DPB is exactly as deep as its 3 reference frames, so 8.2.5's +//! sliding window unmarks a picture in the very access unit whose C.4.5.3 bump evicts +//! it. `plan_to_dxva` released that surface before assigning the decode target one, and +//! `SlotMap::assign` handed it straight back — `CurrPic` and a `RefFrameList` entry +//! naming one surface, on 117 of 120 access units. Found 2026-08-07 by planning our own +//! host's output on the CPU, fixed with the same deferral the AV1 rung got +//! ([`pf_dxvadec::DecodePlanDxva::release_after_decode`]), and the stream is now +//! vendored so `low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec` +//! holds the rung to what it streams rather than only to what it conforms to. +//! +//! HEVC is EXEMPT from that defect, and since 2026-08-07 that is a measurement rather +//! than an argument: `H265Planner` snapshots `dpb_refs` after `decode_rps`, so an +//! RPS-dropped picture never reaches `RefPicList`, and a vendored low-delay HEVC +//! stream from the same host confirms it — 115 of its 120 access units retire a +//! picture, 0 alias, and all 115 WOULD alias if the snapshot moved one call earlier. +//! `low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec` is the pixel +//! leg; pf-dxvadec's `pic_h265` tests pin the numbers and drive the counterfactual +//! through the conversion. +//! * **AV1** — wired in M7, and frame-hash parity on the SAME two GPUs since 2026-08-07: +//! 250/250 delivered frames bit-identical to libavcodec on the RTX 3500 Ada and on the +//! Intel Arc. It streams 4K60 on both with a clean 5-minute soak, but that is throughput +//! and not pixels — the leg streamed exactly as cleanly while 186 and 245 of those 250 +//! frames were WRONG, which is what the first run of this harness measured on 2026-08-07 +//! and what `av1_divergence_map` (below) records. The defect was one line of DPB +//! bookkeeping in [`pf_dxvadec::plan_to_dxva_av1`]: it released the picture this frame's +//! own `refresh_frame_flags` displaces before assigning the decode target a slot, and +//! `SlotMap::assign` hands back the slot just vacated, so 268 of the vector's 274 frames +//! named one surface as both `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry. +//! [`NativeD3d11Decoder::frame_av1`] now applies the conversion's +//! `release_after_decode` once the decode op is issued. +//! +//! Since 2026-08-07 a SECOND AV1 stream runs beside the vector: our own host's 4K +//! output, `low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec`. Not for +//! the aliasing — the vector covers that better than any host stream could — but +//! because every frame of the vector is `tile_cols = tile_rows = 1`, so every tile +//! array `plan_to_dxva_av1` fills had only ever been written at index 0. Our encoder +//! splits 4K into two tile rows carried in one Tile Group OBU, which is two tile +//! RECORDS from one group; 1440p and below measured single-tile, so 4K is the only +//! shape that has it. +//! +//! ⚠ Still no SOAK on the goldens, so this leg's evidence is two vendored streams on two +//! vendors — narrower than the H.264/H.265 legs above. ⚠⚠ And both are FILES. "250/250 +//! delivered frames bit-identical" was true for the entire period the host was shipping +//! only the FIRST TILE of every 4K frame: that verification ran against a vendored file +//! while the truncation lived in packetisation, and this suite stayed green throughout. +//! Nothing here covers fragmentation, reassembly or loss. //! //! A refusal or an init failure logs and falls through to the standard ladder, so neither the //! pin nor the `auto` admission can cost a session its decoder. @@ -175,6 +223,20 @@ struct Submission { /// other two codecs do not (see [`NativeD3d11Decoder::frame_av1`]). All three /// conversions produce it; dropping it here made the AV1 leak invisible. setup_id: u64, + /// Surfaces this picture's own end-of-picture bookkeeping retires while the + /// submission still NAMES them, released once the decode op has been issued + /// ([`NativeD3d11Decoder::release_deferred`]). + /// + /// The caller's half of [`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`] + /// and [`pf_dxvadec::DecodePlanDxva::release_after_decode`]. Dropping it decodes + /// the picture into a surface it predicts from — 268 of the vendored AV1 vector's + /// 274 frames, and 297 of every 300 access units of low-delay H.264, which is what + /// every punktfunk host emits. + /// + /// Empty on H.265 alone, and that is structural rather than lucky: `H265Planner` + /// snapshots `dpb_refs` AFTER `decode_rps`, so a picture this AU's RPS dropped is + /// never in the set `RefPicList` is built from. + release_after_decode: Vec, /// Which codec's slice-control record the packer's locations become. codec: Codec, /// What the hand-off needs to blit this picture. @@ -436,11 +498,21 @@ impl NativeD3d11Decoder { // The plan needed a substitute for something lost. Fold it, ask for recovery, // and do NOT submit: a concealed picture is not fit to present, and submitting // it would put a wrong reference in the DPB for every AU after it. + // + // The deferred releases still run: they are the planner's verdict on + // pictures that left the DPB, and a converted-but-unsubmitted AU took its + // slot just the same. + self.release_deferred(&submission); self.health.note(true, false, 0); self.want_recovery = true; return Ok(None); } - let frame = match self.submit(au, &submission) { + let submitted = self.submit(au, &submission); + // The surfaces the conversion refused to release, freed now that the decode op + // has been issued (or has failed, where dropping them would leak just the + // same) — see [`Self::release_deferred`]. + self.release_deferred(&submission); + let frame = match submitted { Ok(frame) => frame, Err(e) => { self.health.note(false, true, 0); @@ -452,6 +524,36 @@ impl NativeD3d11Decoder { Ok(Some(frame)) } + /// Apply a submission's [`Submission::release_after_decode`] — the surfaces its + /// conversion held back because the submission still NAMED them. + /// + /// Safe here and nowhere earlier: the decode op has been issued (or will never be), + /// so nothing can be assigned these surfaces before the next access unit is + /// converted. Dropping the list instead holds a surface per AU and reaches + /// `SlotError::Full` within the ledger's depth — which is why every exit of + /// [`Self::decode`] runs it, the concealed and failed ones included. + /// + /// Empty on H.265, whose planner cannot produce the shape; populated on nearly + /// every H.264 and AV1 picture. + fn release_deferred(&mut self, sub: &Submission) { + let Some(session) = self.session.as_mut() else { + return; + }; + for &id in &sub.release_after_decode { + if !session.slots.release(id) { + // Never fatal, and never silent — but `debug!` rather than `warn!`, + // because there is a LEGITIMATE way to get here: a renegotiation + // replaces the whole `Session` (and with it the slot map) inside + // `plan`, while the planner's own drain reports every drained picture + // in the same AU's `removed`. Those ids belong to the map that no + // longer exists, so every one of them misses and nothing is wrong. + // Outside a rebuild it means the conversion and the ledger disagree + // about the DPB, which the surrounding rebuild log makes separable. + tracing::debug!(id, "a deferred release named a picture holding no surface"); + } + } + } + /// One AV1 **temporal unit**: decode every frame in it, present at most one. /// /// This is the whole of what AV1 adds to this rung's contract, and it is the @@ -542,6 +644,21 @@ impl NativeD3d11Decoder { /// rung closes it in `pf_vkdecode::decoder_av1`; this is the same close, and it /// runs on the concealed path too, because a converted-but-unsubmitted frame /// took a slot just the same. + /// + /// # The surfaces the conversion refuses to release + /// + /// The second of the two slot releases below, and the caller's half of + /// [`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`]. AV1 applies + /// `refresh_frame_flags` AFTER the frame is decoded (7.20), so a frame that reads + /// a slot its own refresh overwrites is ordinary — 268 of the vendored vector's + /// 274 frames — and `plan_to_dxva_av1` therefore hands those pictures back rather + /// than releasing them, because `SlotMap::assign` would return the surface just + /// vacated to `setup_slot` and the submission would name one surface as both + /// `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry. Releasing them + /// HERE is safe for the same reason the `refresh_frame_flags == 0` release below + /// is: the decode op has been issued, so nothing can be assigned them before the + /// next frame. Dropping them instead holds a surface per frame and exhausts the + /// nine-slot ledger within ten. fn frame_av1( &mut self, au: &[u8], @@ -554,6 +671,72 @@ impl NativeD3d11Decoder { return self.show_existing_av1(plan); } let sub = self.plan_frame_av1(au, plan)?; + // ⚠ The decode's `Result` is held rather than `?`-ed, so that the two slot + // releases below run on the FAILURE path too. `decode_av1` treats an error + // here as a health note and keeps the session — it does not rebuild the slot + // map — so an early return leaked a surface per failed frame and would reach + // `SlotError::Full` after nine, a session dying of an error it had already + // recovered from. + // + // ⚠⚠ That closes THIS frame's leak and not the unit's: `decode_av1` returns on + // the first failing frame and abandons the rest of the temporal unit's plans, + // whose removals are then never released and whose stored ids are never + // assigned — the ledger and the planner's store desynchronise. 24 of the + // vendored vector's 250 units carry a second frame, so it is not hypothetical. + // Left as it is: recovering a partly-decoded unit means deciding what to do + // with the frames after the failure, which is the pump's question and not this + // function's, and the failure already ends in a keyframe request. + let shown = self.decode_and_present_av1(au, &sub, damaged); + if shown.is_err() { + // The surface's `held` entry, on the path that now CONTINUES rather than + // returning early. The slot map says this surface holds THIS picture while + // the surface still carries whatever the previous occupant decoded, so a + // later `show_existing_frame` naming it would blit the old picture's pixels + // with the old picture's geometry and colour. The `damaged` path has + // cleared it for that reason since M7; the failure path never reached this + // far before. + if let Some(session) = self.session.as_mut() { + if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) { + *held = None; + } + } + } + + // The surfaces this frame's own refresh displaced while its submission still + // NAMED them (fn docs). Released here for the same reason the block below + // waits: the decode op has been issued, so nothing can be assigned them + // until the next frame — and on the `damaged` and failed paths there is no + // op at all, where dropping the release would leak a surface just the same. + self.release_deferred(&sub); + + // The slot nothing will ever ask for again (fn docs). Released AFTER the + // blit above, so the surface is read before anything can be assigned it. + if plan.header.refresh_frame_flags == 0 { + if let Some(session) = self.session.as_mut() { + if session.slots.release(sub.setup_id) { + tracing::trace!( + id = sub.setup_id, + slot = sub.setup_slot, + "AV1 frame refreshes no reference slot — returning its surface" + ); + } + if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) { + *held = None; + } + } + } + shown + } + + /// Submit one converted AV1 frame and blit it if it displays — the part of + /// [`Self::frame_av1`] that can fail, split out so its caller can run the slot + /// releases on the failure path as well as on the two clean ones. + fn decode_and_present_av1( + &mut self, + au: &[u8], + sub: &Submission, + damaged: bool, + ) -> Result> { let shown = if damaged { // Converted (so the slot map stayed in step with the planner's store), // deliberately not submitted (fn docs). @@ -572,7 +755,7 @@ impl NativeD3d11Decoder { } None } else { - self.decode_into(au, &sub)?; + self.decode_into(au, sub)?; if let Some(session) = self.session.as_mut() { // What this surface now holds, for a later `show_existing_frame`. if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) { @@ -585,23 +768,6 @@ impl NativeD3d11Decoder { None } }; - - // The slot nothing will ever ask for again (fn docs). Released AFTER the - // blit above, so the surface is read before anything can be assigned it. - if plan.header.refresh_frame_flags == 0 { - if let Some(session) = self.session.as_mut() { - if session.slots.release(sub.setup_id) { - tracing::trace!( - id = sub.setup_id, - slot = sub.setup_slot, - "AV1 frame refreshes no reference slot — returning its surface" - ); - } - if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) { - *held = None; - } - } - } Ok(shown) } @@ -633,6 +799,7 @@ impl NativeD3d11Decoder { slice_ranges: Vec::new(), setup_slot: dxva.setup_slot, setup_id: dxva.setup_id, + release_after_decode: dxva.release_after_decode, codec: Codec::Av1, facts: PictureFacts { colour: colour_of(plan.picture.colour), @@ -736,6 +903,20 @@ impl NativeD3d11Decoder { slice_ranges: dxva.slice_ranges, setup_slot: dxva.setup_slot, setup_id: dxva.setup_id, + // ⚠⚠ The suspicion of 2026-08-07 was RIGHT, and the shape is the + // ordinary case rather than a corner: on every stream a punktfunk + // host emits, 297 of 300 access units name one surface as both + // `CurrPic` and a `RefFrameList` entry. `H264Planner` snapshots + // `dpb_refs` before 8.2.5's marking, and low-delay H.264 — + // `max_num_reorder_frames = 0`, so a picture is output the moment + // it decodes — puts the unmarking and the eviction in one AU. + // NVENC seals it by writing `max_num_ref_frames = 3` AND + // `max_dec_frame_buffering = 3`: a DPB exactly as deep as the + // reference count. The vendored vector cannot reach the shape (a + // level-derived DPB of 7 against 2 reference frames, and it + // reorders), which is why it measured zero for two milestones. + // See `pf_dxvadec::DecodePlanDxva::release_after_decode`. + release_after_decode: dxva.release_after_decode, codec: Codec::H264, facts: PictureFacts { colour: colour_of(plan.picture.colour), @@ -795,6 +976,16 @@ impl NativeD3d11Decoder { slice_ranges: dxva.slice_ranges, setup_slot: dxva.setup_slot, setup_id: dxva.setup_id, + // HEVC is the one of the three that needs no deferral, and it is + // STRUCTURAL rather than measured: `H265Planner` snapshots + // `dpb_refs` AFTER `decode_rps` has updated the DPB, so a picture + // this AU's RPS dropped is never in the snapshot `RefPicList` is + // built from, and nothing later in the AU unmarks anything. Both + // other codecs snapshot BEFORE their marking, and both needed the + // deferral. Now measured as well as argued: a low-delay HEVC + // stream from the same host that aliases 297 of 300 H.264 access + // units aliases 0 of 300 here. + release_after_decode: Vec::new(), codec: Codec::H265, facts: PictureFacts { colour: colour_of(plan.picture.colour), @@ -1578,9 +1769,52 @@ mod parity { /// rungs measured against two copies of a golden set is two measurements, and the /// point of this file is that they are one. const GOLDENS_H264: &str = include_str!("../../pf-vkdecode/tests/data/test-25fps.nv12.sha256"); + + /// **Our own host's low-delay H.264** and its goldens — the stream the vendored + /// vector cannot be. 120 pictures of 640x480 IPPP with `max_num_reorder_frames = 0` + /// and a DPB exactly as deep as its 3 reference frames, so 8.2.5's sliding window + /// unmarks the oldest reference in the very access unit whose C.4.5.3 bump evicts + /// it: `dpb.removed` and `dpb_refs` intersect on 117 of the 120, and the conversion + /// used to release those surfaces before assigning the decode target one. + /// + /// The vendored vector passed 250/250 on four GPUs across two milestones while + /// that was true of every stream this program ships. Provenance, the + /// `punktfunk-host spike` command and the ffmpeg cross-check are in the golden + /// file's header. + const LOWDELAY_H264: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264"); + const GOLDENS_LOWDELAY: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256"); + const LOWDELAY_FRAME_COUNT: usize = 120; const GOLDENS_H265: &str = include_str!("../../pf-vkdecode/tests/data/test-25fps-h265.nv12.sha256"); + /// **Our own host's low-delay HEVC** and its goldens — the H.265 twin of + /// [`LOWDELAY_H264`], vendored for the opposite reason. + /// + /// The H.264 stream is here because this rung was WRONG and only that shape could + /// show it. This one is here because HEVC is believed RIGHT — `H265Planner` + /// snapshots `dpb_refs` after `decode_rps`, so an RPS-dropped picture is never in + /// the marked set `RefPicList` is built from, and `plan_to_dxva_h265` is the one + /// conversion of the three that still releases inline. 120 pictures of 640x480 + /// IPPP, `sps_max_num_reorder_pics = 0`, a five-picture DPB against four marked + /// references: 115 of the 120 access units retire a picture, `removed ∩ dpb_refs` + /// is 0 of 120, and all 115 would alias under the other snapshot ordering + /// (pf-dxvadec's `pic_h265` tests pin every one of those numbers, and drive the + /// counterfactual through the conversion itself). + /// + /// Provenance, the `punktfunk-host spike` command and the ffmpeg cross-check are in + /// the golden file's header. + const LOWDELAY_H265: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h265"); + const GOLDENS_LOWDELAY_H265: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256"); + + /// The HEVC low-delay stream's frame count. A separate constant from + /// [`LOWDELAY_FRAME_COUNT`] on purpose: two files, two encoder runs, and one + /// regenerated at another length must fail on its own leg. + const LOWDELAY_H265_FRAME_COUNT: usize = 120; + /// Both vendored vectors are 250 display frames. const FRAME_COUNT: usize = 250; @@ -1614,6 +1848,42 @@ mod parity { const AV1_DECODED_COUNT: usize = 274; const AV1_SHOWN_COUNT: usize = 250; + /// The vendored AV1 vector's render region, and what its goldens hash. + const DISPLAY_AV1: (u32, u32) = (320, 240); + + /// **Our own host's AV1**, and the only stream this rung decodes with more than + /// ONE TILE. + /// + /// Unlike the H.264 and H.265 low-delay siblings this is not about the + /// release-ordering defect — the vendored vector already aliases on 268 of its 274 + /// frames, which is exactly why parity caught that one here. It closes a different + /// gap: no host-generated AV1 stream had pixel coverage anywhere, and our encoder's + /// AV1 is structurally unlike the vector. At 4K the split encode emits + /// `tile_cols = 1, tile_rows = 2` — `height_in_sbs_minus_1 = [16, 16]` — with both + /// tiles in a SINGLE Tile Group OBU. 1440p and below measured single-tile, so 4K is + /// the only shape that has the property; 60 frames rather than 120 pays for it, at + /// 261 KB. + /// + /// ⚠ A file fixture is not the wire path, and on AV1 that distinction has already + /// cost a release. "250/250 delivered frames bit-identical" was true for the whole + /// period the host was shipping only the first tile of every 4K frame — the + /// verification ran against a vendored file and the truncation lived in + /// packetisation. This leg gives the multi-tile shape pixel coverage on the DECODE + /// rung and proves nothing about fragmentation, reassembly or loss. + const LOWDELAY_AV1: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1"); + const GOLDENS_LOWDELAY_AV1: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256"); + + /// 60 units, 60 decoded, 60 shown — three constants, never derived from each + /// other. Our host emits one shown frame per temporal unit with no hidden frames + /// and no `show_existing_frame`, which is the OPPOSITE shape to the vendored + /// vector's 250 / 274 / 250 and the reason the harness takes all three. + const LOWDELAY_AV1_UNIT_COUNT: usize = 60; + const LOWDELAY_AV1_DECODED_COUNT: usize = 60; + const LOWDELAY_AV1_SHOWN_COUNT: usize = 60; + const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160); + /// The golden file's hash lines (comments and blanks skipped). fn golden_hashes(file: &'static str) -> Vec<&'static str> { file.lines() @@ -1821,7 +2091,7 @@ mod parity { /// the whole difference. `display` is still the planner's own output list; /// AV1 has no bumping process, so a picture is output by the unit that shows /// it and there is no flush to drain at the end. - fn order_av1(units: &[&[u8]]) -> Order { + fn order_av1(units: &[&[u8]], render: (u32, u32)) -> Order { let mut planner = pf_dxvadec::Av1Planner::new(); let mut order = Order { decode: Vec::new(), @@ -1841,8 +2111,8 @@ mod parity { ); assert_eq!( (plan.picture.render_width, plan.picture.render_height), - (320, 240), - "unit {index}: the goldens are the 320x240 render region" + render, + "unit {index}: the goldens are the {render:?} render region" ); if let Some(id) = plan.dpb.stored { order.decode.push(id); @@ -2052,6 +2322,11 @@ mod parity { decoder .submit(au, &sub) .unwrap_or_else(|e| panic!("AU {index}: submit failed — {e:#}")); + // This harness drives `plan` + `submit` rather than `decode`, so it owes + // the deferred releases `decode` would have applied. Not optional + // bookkeeping: on a low-delay stream nearly every AU defers, and a loop + // that drops them exhausts the ledger within the DPB's depth. + decoder.release_deferred(&sub); let session = decoder.session.as_ref().expect("submit built a session"); let pool = session.pool.clone(); let bytes = readback.read(&decoder.device, &pool, slice, display); @@ -2124,18 +2399,45 @@ mod parity { /// ⚠ Still unexercised, because the vendored vector has none: /// `show_existing_frame`. fn av1_parity_run(units: &[&[u8]], order: &Order, goldens: &[&str]) { + av1_parity_run_against( + units, + order, + goldens, + AV1_UNIT_COUNT, + AV1_DECODED_COUNT, + AV1_SHOWN_COUNT, + "AV1", + ); + } + + /// [`av1_parity_run`] with its stream's own counts, for the leg that does not + /// decode the vendored vector. + /// + /// The three counts are three parameters, never derived from one another: the + /// vendored vector is 250 units / 274 decoded / 250 shown, and our host's stream is + /// 60 / 60 / 60. A harness that computed "hidden = 0" or "decoded = units" from + /// either would silently stop checking the other. + fn av1_parity_run_against( + units: &[&[u8]], + order: &Order, + goldens: &[&str], + unit_count: usize, + decoded_count: usize, + shown_count: usize, + label: &str, + ) { assert_eq!( units.len(), - AV1_UNIT_COUNT, - "the IVF reader disagrees with the vector's temporal-unit count" + unit_count, + "{label}: the IVF reader disagrees with the stream's temporal-unit count" ); - assert_eq!(order.decode.len(), AV1_DECODED_COUNT); + assert_eq!(order.decode.len(), decoded_count); assert_eq!(order.per_unit.len(), units.len()); assert_eq!(order.display.len(), goldens.len()); let luid = pinned_adapter(); let mut decoder = NativeD3d11Decoder::new(Codec::Av1, StreamFormat::SDR_420_8, luid, false) - .unwrap_or_else(|e| panic!("AV1: the box must host AV1 Profile 0 — {e:#}")); + .unwrap_or_else(|e| panic!("{label}: the box must host AV1 Profile 0 — {e:#}")); let mut readback = Readback { ctx: decoder.context.clone(), staging: None, @@ -2183,20 +2485,23 @@ mod parity { decoded += 1; } } - assert_eq!(decoded, AV1_DECODED_COUNT); + assert_eq!(decoded, decoded_count); assert_eq!( - presented, AV1_SHOWN_COUNT, - "every unit of this vector shows exactly one frame, so the production \ - path must have handed back {AV1_SHOWN_COUNT} pictures" + presented, shown_count, + "{label}: every unit of this stream shows exactly one frame, so the \ + production path must have handed back {shown_count} pictures" ); - let hidden = AV1_DECODED_COUNT - presented; + let hidden = decoded_count - presented; assert_eq!( hidden, - AV1_DECODED_COUNT - AV1_SHOWN_COUNT, - "the rung must have decoded 24 frames it never handed back — this counts \ - what `decode_av1` RETURNED against what it decoded, so at zero the \ - `!sub.show` suppression is not working (or this vector stopped hiding \ - frames, which `the_av1_vector_hides_frames…` would catch first)" + decoded_count - shown_count, + "{label}: the rung must have decoded {} frames it never handed back — this \ + counts what `decode_av1` RETURNED against what it decoded, so a mismatch \ + on the vendored vector means the `!sub.show` suppression is not working \ + (or it stopped hiding frames, which `the_av1_vector_hides_frames…` would \ + catch first). On a stream with no hidden frames both sides are zero and \ + this is a tautology — deliberately, so one harness serves both shapes", + decoded_count - shown_count ); let mut mismatches = 0usize; @@ -2206,7 +2511,7 @@ mod parity { .unwrap_or_else(|| panic!("display frame {n} names PicId {id}, never decoded")); if got != golden { if mismatches < 10 { - eprintln!("AV1: display frame {n} (PicId {id}): {got} != {golden}"); + eprintln!("{label}: display frame {n} (PicId {id}): {got} != {golden}"); } mismatches += 1; } @@ -2214,27 +2519,223 @@ mod parity { assert_eq!( mismatches, 0, - "AV1: {mismatches}/{} frames diverge from libavcodec (first 10 above; frame \ + "{label}: {mismatches}/{} frames diverge from libavcodec (first 10 above; frame \ 0 is a key frame — if IT mismatches suspect the readback geometry \ (pitch/crop/plane offset) or the tile records rather than the reference \ handling)", goldens.len() ); eprintln!( - "AV1: {} delivered frames bit-identical to libavcodec, {hidden} hidden frames \ - decoded and withheld", + "{label}: {} delivered frames bit-identical to libavcodec, {hidden} hidden \ + frames decoded and withheld", goldens.len() ); } + /// The AV1 leg's post-mortem: one line per DISPLAY frame, its verdict against the + /// goldens beside the plan facts that could explain it. + /// + /// Not a gate — it asserts nothing and always "passes". It exists because + /// [`av1_every_delivered_frame_hashes_bit_identical_to_libavcodec`] FAILED on both + /// GPUs of `.221` the first time it was ever run, and a count of diverging frames + /// is not a lead. This is what turned that count into one, on 2026-08-07: + /// + /// * **NVIDIA RTX 3500 Ada** — display frames 0..=63 bit-identical, then every one + /// of the remaining 186 diverged. The first bad frame was the one whose + /// `order_hint` first reaches **64**, and its error was 174 luma pixels in a + /// single 16x24 block (max |delta| 8, chroma untouched) which then propagated + /// through prediction. The stream keeps the key frame (`order_hint` 0) in the + /// BWDREF and ALTREF2 slots for its whole length, so 64 is where the distance to + /// it reaches the edge of what `get_relative_dist` can represent at + /// `OrderHintBits = 7`. + /// * **Intel Arc** — only display frames 0, 1, 2, 3 and 10 were bit-identical, and + /// the divergence was STRUCTURAL rather than marginal (47% of luma at the first + /// bad frame, max |delta| 242, chroma wrong too): a frame predicted from the + /// wrong picture, not a filter rounding. + /// + /// Both were deterministic — three runs each, identical first-divergent frame and + /// identical hashes — so neither was a race against the decode queue. + /// + /// **⚠ Both were ONE defect, and the two unlike signatures argued for two.** The + /// submission named a single surface as `CurrPicTextureIndex` and as a + /// `RefFrameMapTextureIndex` entry on 268 of the vector's 274 frames — decode into + /// the picture you predict from — because [`pf_dxvadec::plan_to_dxva_av1`] released + /// the displaced reference before assigning the decode target its slot. Intel + /// followed the aliased surface immediately; NVIDIA tolerated it until the order-hint + /// wrap put one block's prediction on the far side of it. Fixing that one thing took + /// BOTH vendors to 250/250. Two readings this map invited and that were wrong: + /// "`primary_ref_frame` or its resolution" (Intel's one correct late frame is + /// PRIMARY_REF_NONE **because** it is the intra frame, which names no reference and + /// so cannot alias) and "motion-field projection at the `get_relative_dist` sign + /// flip" (the wrap is where an already-aliased surface first mattered on NVIDIA, not + /// what was wrong). Read a signature as evidence about WHERE, not about WHAT. + /// + /// Set `PF_AV1_DUMP=` to also write a few frames' raw NV12 to the temp + /// directory. That is how "how badly" was answered: at a frame where ONE vendor + /// hashes correctly, that vendor's bytes are libavcodec's bytes and so a valid + /// reference for the other's, and `ffmpeg -f rawvideo -pix_fmt nv12` regenerates + /// the rest (the golden file's header carries the exact command). + #[test] + #[ignore = "diagnostic, needs a Windows D3D11 video device (see module docs)"] + fn av1_divergence_map() { + let units = split_ivf(TEST_25FPS_AV1); + let order = order_av1(&units, DISPLAY_AV1); + let goldens = golden_hashes(GOLDENS_AV1); + + // Plan facts per PicId, from a planner run alongside the decoder's own. + let mut facts: HashMap = HashMap::new(); + let mut hidden: std::collections::HashSet = std::collections::HashSet::new(); + { + let mut planner = pf_dxvadec::Av1Planner::new(); + for unit in &units { + for plan in planner.plan_au(unit).expect("the clean vector plans") { + let Some(id) = plan.dpb.stored else { continue }; + let h = &*plan.header; + if !h.show_frame { + hidden.insert(id); + } + let mut refs = String::new(); + for r in plan.refs.iter() { + match r { + Some(r) => { + refs.push_str(&format!("{}/{} ", r.slot, r.id)); + } + None => refs.push_str("-/- "), + } + } + facts.insert( + id, + format!( + "ft={} show={} oh={:3} pri={} refresh={:#06x} grain={} seg={} \ + sr={} warp={} refmvs={} skip={} refsel={} tiles={}x{} \ + lf={:?} lfsharp={} lfdelta={}{} refd={:?} moded={:?} \ + cdefbits={} lr={:?} refs=[{}]", + h.frame_type as u8, + u8::from(h.show_frame), + h.order_hint, + h.primary_ref_frame, + h.refresh_frame_flags, + u8::from(h.film_grain_params.apply_grain), + u8::from(h.segmentation_params.segmentation_enabled), + u8::from(h.use_superres), + u8::from(h.allow_warped_motion), + u8::from(h.use_ref_frame_mvs), + u8::from(h.skip_mode_present), + u8::from(h.reference_select), + h.tile_info.tile_cols, + h.tile_info.tile_rows, + h.loop_filter_params.loop_filter_level, + h.loop_filter_params.loop_filter_sharpness, + u8::from(h.loop_filter_params.loop_filter_delta_enabled), + u8::from(h.loop_filter_params.loop_filter_delta_update), + h.loop_filter_params.loop_filter_ref_deltas, + h.loop_filter_params.loop_filter_mode_deltas, + h.cdef_params.cdef_bits, + h.loop_restoration_params.frame_restoration_type, + refs.trim_end(), + ), + ); + } + } + } + + let luid = pinned_adapter(); + let mut decoder = NativeD3d11Decoder::new(Codec::Av1, StreamFormat::SDR_420_8, luid, false) + .expect("the box must host AV1 Profile 0"); + let mut readback = Readback { + ctx: decoder.context.clone(), + staging: None, + }; + // Raw NV12 for a few display frames is kept as well as its hash, so a + // divergence can be classified by plane and magnitude against a vendor whose + // hash at that same frame MATCHES the golden. It has to be captured inside + // the loop: surfaces are recycled, so by the end of the run the slot that + // held an early picture holds someone else's pixels. + let dump_tag = std::env::var("PF_AV1_DUMP").ok(); + let wanted: Vec = if dump_tag.is_some() { + [3usize, 4, 10, 63, 64] + .iter() + .filter_map(|&n| order.display.get(n).copied()) + .collect() + } else { + Vec::new() + }; + let mut by_id: HashMap = HashMap::new(); + for (index, unit) in units.iter().enumerate() { + decoder.decode_av1(unit).expect("decode"); + for &id in &order.per_unit[index] { + let (slot, f, pool) = { + let session = decoder.session.as_ref().expect("session"); + let slot = session.slots.slot_of(id).expect("slot"); + let f = session.held[usize::from(slot)].expect("facts"); + (slot, f, session.pool.clone()) + }; + let bytes = + readback.read(&decoder.device, &pool, u32::from(slot), (f.width, f.height)); + if wanted.contains(&id) { + let tag = dump_tag.as_deref().unwrap_or("x"); + let path = std::env::temp_dir().join(format!("pf-nv12-{tag}-pic{id}.bin")); + std::fs::write(&path, &bytes).expect("write the dump"); + eprintln!("dumped pic {id} -> {}", path.display()); + } + by_id.insert(id, sha256_hex(&bytes)); + } + } + + eprintln!("=== MAP BEGIN ==="); + for (n, (id, golden)) in order.display.iter().zip(goldens.iter()).enumerate() { + let got = by_id.get(id).expect("decoded"); + eprintln!( + "disp {n:3} pic {id:3} {} | {}", + if got == golden { "OK " } else { "BAD" }, + facts.get(id).map(String::as_str).unwrap_or("?") + ); + } + eprintln!("=== HIDDEN ==="); + let mut h: Vec = hidden.into_iter().collect(); + h.sort_unstable(); + for id in h { + eprintln!( + "hidden pic {id:3} | {}", + facts.get(&id).map(String::as_str).unwrap_or("?") + ); + } + eprintln!("=== MAP END ==="); + } + #[test] #[ignore = "needs a Windows D3D11 video device (see module docs)"] fn av1_every_delivered_frame_hashes_bit_identical_to_libavcodec() { let units = split_ivf(TEST_25FPS_AV1); - let order = order_av1(&units); + let order = order_av1(&units, DISPLAY_AV1); av1_parity_run(&units, &order, &golden_hashes(GOLDENS_AV1)); } + /// **Our own host's AV1, at the only resolution where it emits more than one tile.** + /// + /// The leg above runs a vector whose every frame is `tile_cols = tile_rows = 1`, so + /// every tile field `plan_to_dxva_av1` fills is the degenerate case. This stream is + /// `tile_rows = 2` on all 60 frames with both tiles in one Tile Group OBU, which is + /// the 4K split-encode shape the host actually ships — and it is 4K, so the + /// readback moves 12.4 MB per frame rather than 115 KB. See [`LOWDELAY_AV1`] for + /// what it does and does not cover; the short version is that it is a file, and the + /// last AV1 truncation lived somewhere a file cannot reach. + #[test] + #[ignore = "needs a Windows D3D11 video device (see module docs)"] + fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() { + let units = split_ivf(LOWDELAY_AV1); + let order = order_av1(&units, DISPLAY_LOWDELAY_AV1); + av1_parity_run_against( + &units, + &order, + &golden_hashes(GOLDENS_LOWDELAY_AV1), + LOWDELAY_AV1_UNIT_COUNT, + LOWDELAY_AV1_DECODED_COUNT, + LOWDELAY_AV1_SHOWN_COUNT, + "AV1 (low-delay host stream, 4K two-tile)", + ); + } + #[test] #[ignore = "needs a Windows D3D11 video device (see module docs)"] fn h264_every_frame_hashes_bit_identical_to_libavcodec() { @@ -2251,6 +2752,32 @@ mod parity { ); } + /// The leg that would have caught this rung's H.264 defect, and the only one that + /// could: **our own host's output** rather than a conformance vector. + /// + /// `h264_every_frame_hashes_bit_identical_to_libavcodec` above passed 250/250 on an + /// RTX 4090, an AMD iGPU, an RTX 3500 Ada and an Intel Arc while this rung was + /// naming one surface as both `CurrPic` and a `RefFrameList` entry on 99% of the + /// access units of every stream punktfunk actually streams. The vector cannot reach + /// the shape — see [`LOWDELAY_H264`] — so no amount of running it harder would have + /// found this. That is the lesson worth keeping: a conformance vector proves + /// conformance to ITSELF, and the encoder we ship behind is a different stream. + #[test] + #[ignore = "needs a Windows D3D11 video device (see module docs)"] + fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h264_aus(LOWDELAY_H264); + let order = order_h264(&aus); + parity_run( + Codec::H264, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_LOWDELAY), + LOWDELAY_FRAME_COUNT, + "H.264 (low-delay host stream)", + ); + } + #[test] #[ignore = "needs a Windows D3D11 video device (see module docs)"] fn h265_every_frame_hashes_bit_identical_to_libavcodec() { @@ -2267,6 +2794,32 @@ mod parity { ); } + /// The HEVC twin of the low-delay H.264 leg — and the one that keeps HEVC's + /// exemption from the release-ordering defect a standing hardware fact. + /// + /// `h265_every_frame_hashes_bit_identical_to_libavcodec` decodes a vector that + /// REORDERS, so it never puts an RPS drop and the eviction it causes in one access + /// unit and cannot see this class at all. This stream does, on 115 of its 120 + /// access units — see [`LOWDELAY_H265`]. If a refactor ever moved `H265Planner`'s + /// snapshot ahead of `decode_rps` (where the other two planners take theirs), this + /// rung would name one surface as both `CurrPic` and a `RefPicList` entry on all + /// 115, and this leg is what would say so in pixels. + #[test] + #[ignore = "needs a Windows D3D11 video device (see module docs)"] + fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h265_aus(LOWDELAY_H265); + let order = order_h265(&aus); + parity_run( + Codec::H265, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_LOWDELAY_H265), + LOWDELAY_H265_FRAME_COUNT, + "H.265 (low-delay host stream)", + ); + } + /// The ten-bit path, which no golden set in this program covered until now. /// /// The HDR legs proved a Main10 session BUILDS and streams clean, which is a @@ -2368,7 +2921,7 @@ mod parity { fn the_ivf_reader_agrees_with_the_planner_and_the_av1_goldens() { let units = split_ivf(TEST_25FPS_AV1); assert_eq!(units.len(), AV1_UNIT_COUNT, "AV1 temporal units"); - let order = order_av1(&units); + let order = order_av1(&units, DISPLAY_AV1); assert_eq!( order.decode.len(), AV1_DECODED_COUNT, diff --git a/crates/pf-client-core/src/video_software.rs b/crates/pf-client-core/src/video_software.rs index bd37a6dc..718f7729 100644 --- a/crates/pf-client-core/src/video_software.rs +++ b/crates/pf-client-core/src/video_software.rs @@ -45,6 +45,46 @@ //! no equivalent here. rav1d gets the machine's cores, and **at least two frame contexts**; //! [`Av1Software::new`] carries the whole argument, because "at least two" is not a //! performance choice but the difference between an error and `abort()`. +//! +//! # Why this rung is NOT process-isolated +//! +//! The frame-context floor closes the one abort we hit and can prove. It does not make the +//! rung panic-proof, and nothing at this call site can: rav1d exposes dav1d's C ABI, every +//! internal `rav1d_*` entry point is `pub(crate)`, so any reachable panic crosses +//! `extern "C"` as `panic_cannot_unwind` → `abort()`. No `catch_unwind`, no rung demotion +//! and no [`NoSoftwareRung`] refusal can contain it. Counted in rav1d 1.1.0's 60 source +//! files: 285 `unwrap()`, 214 `assert!`, 19 `unreachable!`, 11 `expect()`, 10 `panic!` — +//! 539 sites that are an `abort()` if a stream can reach them. #97 fixed ONE. +//! +//! Isolating the decoder in its own process is the only defence that actually works, and +//! it is deliberately NOT taken. The decision, so it is not re-litigated from scratch: +//! +//! * **The defect is a dependency's, and it is one line.** memorysafety/rav1d#1497 was +//! filed 2026-08-07 with the fix (`is_some_and` for the `unwrap`) and a reproducer. +//! Paying a permanent architectural tax to route around a bug that costs upstream one +//! line is the wrong trade while that line is still plausibly coming. +//! * **The residual risk is real but unquantified.** 539 panic sites is a scary number +//! and a meaningless one: not one of them is known to be reachable from a punktfunk +//! stream. The honest next step is to MEASURE reachability — fuzz this rung with +//! truncated, reordered and bit-flipped AUs and see whether any input aborts — not to +//! buy insurance against a number nobody has bounded. That is cheap; this is not. +//! * **The cost lands on the video path, and on three platforms.** pf-client-core builds +//! into the Linux, Windows and Android clients (the Apple clients decode through +//! VideoToolbox and never reach here). Each needs its own shared-memory transport for +//! `CpuPlanarFrame`s, its own child lifecycle, crash detection and restart, and its own +//! backpressure — and it adds a scheduling boundary to the rung that is ALREADY the +//! slowest one on the ladder. Zero-copy is a hard requirement here; an IPC hop that +//! copies frames would be rejected on its own terms. +//! * **What an abort actually costs is bounded.** This rung is reached because the GPU +//! rungs already failed, so the session is degraded before rav1d sees a byte. Losing +//! the process loses a session the user was going to have a bad time in regardless. +//! That is bad, and it is not the same as losing a working session. +//! +//! **Revisit when the calculus changes, which is a specific event, not a feeling:** a +//! SECOND distinct abort observed in the field, or a fuzzer finding a reachable panic. +//! Either turns this from one upstream bug into a class of them, and a class is what +//! justifies isolation. Until then the floor plus the upstream fix is the proportionate +//! answer, and the fuzzing is the work that would tell us we were wrong. use crate::video::{CpuPlanarFrame, RungLoss}; use crate::video_color::ColorDesc; diff --git a/crates/pf-client-core/src/video_vaapi_native.rs b/crates/pf-client-core/src/video_vaapi_native.rs index bc8cb0e0..51130618 100644 --- a/crates/pf-client-core/src/video_vaapi_native.rs +++ b/crates/pf-client-core/src/video_vaapi_native.rs @@ -47,6 +47,80 @@ //! presenter holds simply stays off the free list until its release token comes //! back. A surface is free when no live picture is bound to it AND no consumer holds //! it — two conditions, tracked separately, because they end at different times. +//! +//! # One access unit in, one frame out — and the queue that makes that honest +//! +//! [`NativeVaapiDecoder::decode`] hands the pump at most one frame, because that is the +//! pump's contract. An access unit can make SEVERAL pictures displayable at once: an +//! IDR with `no_output_of_prior_pics_flag` clear drains the whole DPB, and ordinary +//! reordering bumps a burst whenever the buffer empties. Until 2026-08-07 this rung +//! showed the last of them and retired the rest unshown, and nothing flushed the DPB at +//! the end of a stream — measured at 225 of 250 frames on the vendored H.264 vector, +//! 204 of 250 on H.265 and 45 of 50 on HEVC Main 10, while the D3D11VA and Vulkan rungs +//! delivered every one. +//! +//! The surplus now waits in [`NativeVaapiDecoder::deliverable`] for the access units +//! that output nothing — on a reordering stream, exactly where the reorder buffer +//! refills — bounded by [`max_deliverable`], and [`NativeVaapiDecoder::flush`] drains +//! the tail. It costs the surface pool nothing, because a queued frame INHERITS the +//! claim the picture had as a DPB reference rather than adding a new one; that +//! arithmetic is [`max_deliverable`]'s docs and +//! `the_queue_never_needs_a_surface_the_pool_does_not_have`. +//! +//! ⚠ None of it engages on the wire. punktfunk hosts emit zero-reorder low-delay +//! output, so `outputs` never holds more than one picture, the queue is empty on every +//! access unit, and the flush finds an empty DPB. That is why the defect survived to be +//! found by a conformance vector rather than by a session. +//! +//! # Why this rung is exempt from the decode-into-a-reference defect +//! +//! The D3D11VA and Vulkan rungs both had to grow a `release_after_decode` deferral: +//! their conversions released the pictures an access unit displaces INSIDE the +//! conversion, then assigned the decode target a slot, and [`pf_vaadec::SlotMap::assign`] +//! handed back the slot just vacated — so one surface was named as both the decode +//! target and one of that submission's own references. On H.264 that fired on **117 of +//! 120** access units of a punktfunk host's low-delay output. +//! +//! `pf-vaadec`'s conversions still release inline and this rung is still exempt, for a +//! reason that is a property of the interface rather than of any stream: **a slot is +//! not a surface here.** `plan_to_va` never invents a surface — every reference it can +//! name is read out of the `surfaces` table it is handed — and the decode target is a +//! separate parameter the caller takes from OUTSIDE that table. Two things carry that, +//! and both are load-bearing: +//! +//! * [`Session::acquire_target`] returns the target and the table **together, from one +//! snapshot**, because they are only safe together. A free surface is by construction +//! a surface no slot binds, and the table is exactly what the slots bind, so the +//! target cannot be in it. Taking the two at different moments — the table before +//! this access unit's removals, where references must resolve, and the free surface +//! after them, where the displaced picture's surface has become free — is precisely +//! the defect, and `taking_the_free_surface_after_the_removals_would_hand_out_a_ +//! referenced_surface` shows it happening. +//! * The conversion's half is pinned across every platform by `pf-vaadec`'s +//! `no_submission_names_its_decode_target_as_one_of_its_own_references`, driven over +//! the same low-delay stream, with +//! `taking_the_decode_target_from_the_slot_table_aliases_on_the_low_delay_stream` as +//! the counterfactual that shows the walk can see the defect when it is there. +//! +//! It holds for all three codecs and for the same one-line reason: `setup_surface` +//! reaches the submission at exactly ONE field in each conversion — H.264 and H.265' +//! `curr_pic.picture_id`, AV1's `current_frame`/`current_display_picture` — and every +//! reference field is resolved through the `surfaces` table. HEVC is doubly covered: +//! its per-slice `RefPicList` stores an INDEX into `reference_frames`, so it cannot +//! name a surface that array does not already hold. +//! +//! ⚠ One documented exception, and it is not this defect: `plan_to_va_av1` substitutes +//! a live surface for a reference slot the planner reports empty, and where the store +//! resolved NOTHING at all the fallback is the decode target itself (that conversion's +//! module docs say why, and prefer a resolved reference wherever one exists). It names +//! the target only when there is no other live surface to name, on a frame that is +//! already concealed and will not be shown. +//! +//! ⚠ And one assumption, stated because it is the only way the argument fails: the pool +//! holds DISTINCT `VASurfaceID`s. Two pool entries with one id would let a free index +//! resolve to a bound surface. `vaCreateSurfaces` cannot return duplicates — this rung +//! also destroys each exactly once, which the same duplication would double-free — so +//! it is an assumption about libva rather than about this file. use std::os::fd::AsRawFd as _; use std::os::fd::FromRawFd as _; @@ -614,6 +688,46 @@ impl Drop for VaFrameGuard { // The session // --------------------------------------------------------------------------- +/// The facts that belong to a PICTURE rather than to the access unit that happens to +/// bump it out of the DPB. +/// +/// Recorded when the picture decodes, because that is the only moment they are known +/// to be its own. On a reordering stream the access unit that displays a picture can +/// be several units later and says something different about all three: +/// +/// * **`keyframe`** was the whole defect. [`finish`] used to be handed the CURRENT +/// access unit's `is_idr` and stamp it on whichever picture bumping displaced — so +/// an IDR bumped out three units after it decoded arrived flagged `false`, and the +/// later AU that drained the DPB flagged some ordinary trailing picture as a +/// keyframe. That flag is [`crate::video::DecodedImage::is_keyframe`], the pump's +/// post-loss re-anchor signal: mislabelled, the pump re-anchors on the wrong frame +/// and keeps asking for a keyframe it has already been sent. +/// * **`color`** is read per picture off the ACTIVE SPS/VUI and never latched, +/// because the Windows host switches an HDR desktop to PQ/BT.2020 in-band with a +/// new SPS. Stamping the displaying AU's description onto a picture decoded under +/// the previous one is the same mistake one field along. +/// * **`display`** is a per-FRAME value on AV1 (5.9.6's render size, which may change +/// without a key frame), so a queued frame shown two units later would be cropped +/// to whatever the newest frame asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PictureFacts { + /// Intra keyframe (IDR / AV1 key frame) — THIS picture's, not its display AU's. + keyframe: bool, + color: ColorDesc, + /// The DISPLAY region. A recorded fact rather than a read of `s.shape` because + /// AV1's is per-frame. + display: (u32, u32), +} + +/// A decoded picture that still owes an output, and where it lives. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingPicture { + id: u64, + /// Pool index. + surface: usize, + facts: PictureFacts, +} + /// The live config, context and surface pool for one [`StreamShape`]. struct Session { shape: StreamShape, @@ -626,10 +740,10 @@ struct Session { /// DPB slot → pool index, rebound at ACTIVATION (module docs). `None` for a slot /// holding no picture. slot_surface: Vec>, - /// Decoded pictures the planner has not output yet, `(PicId, pool index)`. - /// Separate from the slot binding because the two end at different times: a - /// non-reference picture leaves the DPB immediately but still owes an output. - pending: Vec<(u64, usize)>, + /// Decoded pictures the planner has not output yet. Separate from the slot + /// binding because the two end at different times: a non-reference picture + /// leaves the DPB immediately but still owes an output. + pending: Vec, slots: pf_vaadec::SlotMap, /// The surface fourcc the pool was created with (NV12 or P010). fourcc: u32, @@ -644,11 +758,18 @@ impl Session { /// the DPB when the planner removes it, stops being pending when it is output, /// and stops being held when the presenter's fence has been waited — and the /// display is usually the LAST of the three. + /// + /// ⚠ `held` covers TWO claims since the deliverable queue existed: a frame the + /// consumer has, and a frame waiting in [`NativeVaapiDecoder::deliverable`] for a + /// later access unit. They are deliberately one flag, because a queued frame has + /// already been exported and its guard is what returns the surface either way — + /// so a frame dropped by [`trim_deliverable`] frees its surface by exactly the + /// same path a presented one does. fn free_surface(&self) -> Option { (0..self.surfaces.len()).find(|i| { !self.held[*i] && !self.slot_surface.contains(&Some(*i)) - && !self.pending.iter().any(|(_, p)| p == i) + && !self.pending.iter().any(|p| p.surface == *i) }) } @@ -686,6 +807,29 @@ impl Session { .collect() } + /// The decode target — pool index and `VASurfaceID` — together with the reference + /// table the conversion resolves against. `None` when the pool is exhausted. + /// + /// **The three are returned together because they are only safe together**, and + /// that is this rung's whole exemption from the aliasing defect the other two + /// backends had to defer their way out of (module docs). A free surface is by + /// definition a surface no slot binds; [`Self::surface_table`] is exactly what the + /// slots bind; so a target drawn from the same snapshot cannot appear in the table, + /// and no reference the conversion resolves through that table can be the surface + /// it is about to write. + /// + /// ⚠ Taking the two at DIFFERENT moments is the defect. References must resolve + /// against the store as it stood BEFORE this access unit's removals, so the table + /// has to be the pre-removal one; and a free list consulted AFTER those removals + /// offers the displaced picture's surface, which the pre-removal table still names. + /// `taking_the_free_surface_after_the_removals_would_hand_out_a_referenced_surface` + /// is that mismatch, made to happen. Returning a tuple is what stops a future edit + /// from reintroducing it by moving one call and not the other. + fn acquire_target(&self) -> Option<(usize, VaSurfaceId, Vec)> { + let index = self.free_surface()?; + Some((index, self.surfaces[index], self.surface_table())) + } + /// Release every libva object this session owns, in creation-reverse order. /// Called explicitly (a `Drop` here could not reach the display). fn destroy(mut self, d: &Display) { @@ -862,11 +1006,128 @@ const _: () = { // The decoder // --------------------------------------------------------------------------- +/// How many display-ready frames this rung holds back for LATER access units before it +/// starts dropping the oldest (see [`trim_deliverable`]). +/// +/// **The DPB's own depth, and derived rather than chosen.** The deepest burst one +/// access unit can bump is the whole DPB plus the picture that caused the bump — an +/// IDR with `no_output_of_prior_pics_flag` clear draining a full buffer, which is +/// exactly what the vendored H.264 vector does three times (measured: seven pictures +/// output by one AU, on a stream whose `max_dpb_frames` is seven). One of them ships +/// immediately, so the CARRY-OVER a bump can leave is the DPB's depth and no more. +/// +/// It costs the pool **at most one surface**, and that is a property of THIS rung +/// rather than a hope. A surface is claimed three separate ways here — a live slot, a +/// pending output, a `held` frame — and a bumped picture MOVES from the first two to +/// the third: [`settle`] takes it out of `pending` in the same breath the bump +/// released its slot, and [`ship`] then marks it `held`. So `|slots ∪ pending| + +/// |queued|` is conserved across a bump, and between bumps it is flat — every access +/// unit decodes one picture into the pool and ships one frame out of the queue. +/// Measured by [`the_queue_never_needs_a_surface_the_pool_does_not_have`] over the +/// real vectors with no device: the deepest simultaneous claim is **9 of a 16-surface +/// pool** on H.264 — where the queue's marginal cost is exactly zero, on the vector +/// with the deepest bursts there are — and 8 of 14 on both HEVC vectors, one more +/// than the same walk with no queue at all. Six or seven of the eight surfaces +/// [`pf_vaadec::config::PRESENTER_HEADROOM`] exists for are still there for the +/// consumer. +/// +/// ⚠ This is where it diverges from the Vulkan rung's [`crate::video_vk_native`] +/// `MAX_DELIVERABLE`, which is `HOLD_HEADROOM - PIPELINE_HOLD` = **1** — and the +/// difference is real, not a disagreement. There a delivered-but-unreleased frame is +/// counted against `picture_count = required_slots + HOLD_HEADROOM` ON TOP of the +/// DPB's own residency (`build_frame` marks the picture held the moment the decoder +/// declares it ready), so its queue and the pipeline share one budget of eight. Here +/// they do not share: the queue inherits the claim the DPB just gave up. A bound of +/// one would have left this rung dropping five of every seven-picture drain — 235 of +/// 250 on the H.264 vector instead of 250, which is most of the defect this fix +/// exists to end still in place. +/// +/// It is a bound and not a plain queue for the reason the Vulkan rung gives at +/// length: "transient" is an assumption about the HOST, and a stream that reliably +/// made two frames displayable per access unit would grow this by one per AU until +/// the pool ran out — after which every AU refuses with "surface pool exhausted", +/// three in a second demote the rung, and nothing in the log would say the cause was +/// a queue that could never drain. +/// +/// ⚠ On H.264 and H.265 that shape cannot arise and the bound is pure defence: an +/// access unit decodes at most ONE picture, so it can only ever output what earlier +/// units decoded, and the queue sheds one per unit — which is why the vendored +/// vectors drop nothing at any depth. An AV1 temporal unit may decode several, and +/// that is where the bound is load-bearing rather than decorative. On the wire none +/// of it engages: punktfunk hosts emit zero-reorder low-delay output, so `outputs` +/// never holds more than one picture and the queue is empty on every single access +/// unit. +fn max_deliverable(s: &Session) -> usize { + s.shape.max_dpb_frames +} + +/// One `warn` per this many dropped deliverable frames, after the first. +/// +/// The same rate limit and the same reasoning as the Vulkan rung's: the shape that +/// drops at all drops on EVERY access unit, and a warn per frame at frame rate buries +/// the log it exists to explain — while a single line at the start of a session that +/// then goes quiet reads as a one-off. So: the first drop in full, then a heartbeat +/// with the running total (~every 5 s at 60 fps). +const DROP_WARN_EVERY: u64 = 300; + +/// Trim the deliverable queue to `cap` by dropping from the FRONT, returning the +/// dropped frames so the caller can count them. +/// +/// Oldest-first, for the Vulkan rung's reason: by the time a queue this deep exists +/// the front frame is several access units stale and the consumer one stage on is +/// itself newest-wins (the pump's `force_send` overwrites an unconsumed frame). +/// Dropping the NEWEST would keep the stalest picture and present the stream in +/// ever-lagging order; dropping the oldest keeps display order for everything that +/// survives and costs the frames that were already too late to matter. +/// +/// ⚠ Called AFTER this access unit's own frame has been taken off the front, so `cap` +/// bounds the CARRY-OVER — what is held back for later access units — exactly as +/// [`max_deliverable`] says. Trimming before the take would make a unit that produced +/// two outputs drop the FIRST of them and ship the second, which is display order +/// inverted inside a single access unit. +/// +/// A dropped frame needs no explicit release: its [`VaFrameGuard`] closes the exported +/// fds and returns the surface to the free list on drop, which is the same path a +/// presented frame takes. Returning them rather than dropping them here is what lets +/// the caller count and log before they go. +/// +/// Pure over the queue, so the bound is CPU-testable without a device. +fn trim_deliverable( + queue: &mut std::collections::VecDeque, + cap: usize, +) -> Vec { + let mut dropped = Vec::new(); + while queue.len() > cap { + match queue.pop_front() { + Some(frame) => dropped.push(frame), + // Unreachable: `len() > cap >= 0` means the queue is non-empty. Written as + // a break rather than an `expect` so a bound of 0 on an empty queue could + // never be a panic in the decode path. + None => break, + } + } + dropped +} + /// The native VAAPI rung. pub(crate) struct NativeVaapiDecoder { display: Display, planner: Planner, session: Option, + /// Display-ready frames not yet handed to the pump, oldest first. + /// + /// [`Self::decode`] is one access unit in, at most one frame out — the pump's + /// contract — while one access unit can bump SEVERAL pictures out of the DPB. The + /// surplus waits here for the access units that output nothing, which on a + /// reordering stream is exactly where the reorder buffer refills. Bounded by + /// [`max_deliverable`]; empty on every access unit of a punktfunk stream. + /// + /// Every frame in here holds a pool surface through its own guard, and survives a + /// session renegotiation intact for the same reason a consumer-held frame does: + /// the exported PRIME fds hold their own reference on the underlying buffer + /// object, so the pixels outlive the `VASurface` (see [`ensure_session`]) and the + /// stale-generation release token is counted rather than applied. + deliverable: std::collections::VecDeque, health: DecodeHealth, /// A concealed AU asks the pump for a re-anchor, through the same one throttle /// every other ask uses. Drained by [`Self::take_recovery_request`]. @@ -906,6 +1167,7 @@ impl NativeVaapiDecoder { display, planner, session: None, + deliverable: std::collections::VecDeque::new(), health: DecodeHealth { // VAAPI has no per-picture decode-status query — there is no // counterpart to Vulkan's `RESULT_STATUS_ONLY`, exactly as on @@ -948,10 +1210,26 @@ impl NativeVaapiDecoder { /// Decode one access unit. /// - /// `Ok(None)` means "no picture from this AU", and covers three different + /// One access unit in, **at most one displayable frame out** — the pump's + /// contract. An access unit that bumps SEVERAL pictures out of the DPB delivers + /// the first of them and holds the rest in [`Self::deliverable`] for the access + /// units that output nothing, which on a reordering stream is exactly where the + /// reorder buffer refills. Nothing is discarded for want of a return slot; the + /// only frames that go unshown are the ones a queue past [`max_deliverable`] + /// drops, and that bound never engages on a punktfunk stream. + /// + /// ⚠ Until 2026-08-07 this shipped `outputs.last()` and RETIRED the rest without + /// ever displaying them, which cost the vendored vectors 25 frames of 250 on + /// H.264 and 46 of 250 on H.265. It could not bite punktfunk's own streams — + /// zero-reorder low-delay output never bumps two pictures at once — but it is + /// exactly the class of defect this program exists to find, and the three + /// hardware legs measured it. + /// + /// `Ok(None)` means "no picture from this AU", and covers four different /// things, deliberately none of them errors: /// - /// * the planner output nothing yet (reordering, or the very first AUs); + /// * the planner output nothing yet and the queue is empty (reordering, or the + /// very first AUs); /// * the picture was CONCEALED — an integrity warning says a reference was /// substituted, so the output is released unshown, [`DecodeHealth::damaged`] /// records it and a re-anchor is requested through the pump's one throttle. @@ -961,7 +1239,8 @@ impl NativeVaapiDecoder { /// * an HEVC RASL picture skipped after an open-GOP join. `PlanError::RaslSkipped` /// is the spec's own answer (8.1.3 NOTE) and must NEVER reach the reanchor /// path — mapping it to an error would make every open-GOP join beg the host - /// for a keyframe it has no reason to send. + /// for a keyframe it has no reason to send; + /// * the whole session was refused before a pool existed. pub(crate) fn decode(&mut self, au: &[u8]) -> Result> { self.drain_releases(); let result = match self.planner { @@ -980,10 +1259,142 @@ impl NativeVaapiDecoder { Ok((_, damaged)) => self.health.note(*damaged, false, 0), Err(_) => self.health.note(false, true, 0), } - result.map(|(frame, _)| frame) + // A REFUSED access unit puts nothing on screen, and that has to hold for the + // frames it had already exported before it failed: the codec arm returns them + // in its `Err`-free half only, so an error path drops them here by never + // reaching the queue at all. + let (fresh, damaged) = result?; + if damaged { + // Concealment answers `Ok(None)` and does NOT drain the queue — the same + // order the Vulkan rung keeps, and it is load-bearing rather than tidy. + // `clears_demotion_streak(delivered, concealed)` is `delivered || !concealed`: + // shipping a queued frame here would report `delivered` on a concealed AU + // and zero the streak, which is exactly the escape hatch that keeps a rung + // concealing FOREVER from holding a frozen picture with no way down. The + // queued frames are clean pictures from earlier units and lose nothing by + // waiting — the pump has just armed a freeze that withholds a non-keyframe + // anyway — so they ship on the next clean access unit. + debug_assert!( + fresh.is_empty(), + "finish ships nothing from a damaged access unit" + ); + return Ok(None); + } + self.deliverable.extend(fresh); + Ok(self.take_deliverable()) } - fn decode_h264(&mut self, au: &[u8]) -> Result<(Option, bool)> { + /// Hand the pump the oldest display-ready frame and bound what stays behind. + /// + /// This access unit's own frame comes off the FRONT first, because the bound is on + /// the CARRY-OVER (see [`trim_deliverable`]): a unit that produced two outputs + /// ships the first and holds the second, rather than dropping the first to ship + /// the second and inverting display order inside one access unit. + fn take_deliverable(&mut self) -> Option { + let shipped = self.deliverable.pop_front(); + // No session means no pool, so there is nothing the queue could legitimately + // still be holding; a cap of 0 is the honest reading of "no surfaces exist" + // rather than a magic number. + let cap = self.session.as_ref().map_or(0, max_deliverable); + // The PRE-trim depth: how far past the bound the queue actually got. Read + // after the trim it would be the constant `cap` every single time. + let queued = self.deliverable.len(); + for frame in trim_deliverable(&mut self.deliverable, cap) { + self.health.note_dropped(); + if self.health.dropped == 1 || self.health.dropped % DROP_WARN_EVERY == 0 { + tracing::warn!( + queued, + cap, + dropped_total = self.health.dropped, + "native VAAPI: more display-ready frames than the pump can take — \ + dropping the oldest so its surface is not held forever" + ); + } + // The guard closes the exported fds and returns the surface; nothing else + // is owed. + drop(frame); + } + shipped + } + + /// Drain the DPB: every picture the planner is still buffering becomes + /// display-ready, in display order, and every id it held is released. + /// + /// # What "end of stream" means for this rung + /// + /// It has no end-of-stream signal and cannot have one: the pump feeds access units + /// until the session ends and then drops the decoder, and there is no call after + /// the last access unit through which a frame could still reach the screen. So the + /// two callers are the two honest ones, and they are the SAME walk rather than a + /// production path and an untested teardown path: + /// + /// * **Teardown** ([`Drop`]), where nothing can be presented and the job is to + /// release — the queue's surfaces AND the DPB's — before the session destroys + /// the pool underneath them. + /// * **A caller that KNOWS the stream ended**, which today is the conformance + /// harness. Without this the vendored vectors lose their tail outright — seven + /// pictures of 250 on H.264, one on H.265, two of 50 on Main 10, decoded and + /// buffered for reorder and never asked for — plus whatever the deliverable + /// queue is still carrying, which is why the counts the legs print (7 / 2 / 2) + /// are not the DPB's tail alone. + /// + /// AV1 has no flush and needs none: it shows at most one frame per temporal unit + /// and buffers no output between them (`Av1Planner` has no counterpart to the + /// H.26x planners' `flush`), so its tail is empty by construction — which the + /// hardware leg's 250 of 250 says out loud. + /// + /// Best-effort by design. An export that fails at teardown must not panic and has + /// nothing to return an error to; it is logged and the picture is dropped, and the + /// harness sees it as a frame count that does not add up, which is loud enough. + pub(crate) fn flush(&mut self) -> Vec { + self.drain_releases(); + let mut out: Vec = std::mem::take(&mut self.deliverable).into(); + let Self { + display, + planner, + session, + release_tx, + .. + } = self; + let Some(s) = session.as_mut() else { + return out; + }; + // The two H.26x planners' `flush` return the same SHAPE under two different + // types (`h264::DpbUpdate` and `h265::DpbUpdate`), which is why this is two + // arms and not one generic call. + let (outputs, removed) = match planner { + Planner::H264(p) => { + let update = p.flush(); + (update.outputs, update.removed) + } + Planner::H265(p) => { + let update = p.flush(); + (update.outputs, update.removed) + } + Planner::Av1(_) => (Vec::new(), Vec::new()), + }; + let claimed = settle(s, &outputs, &removed); + for picture in claimed { + match ship(display, s, picture, release_tx) { + Ok(frame) => out.push(frame), + Err(e) => tracing::warn!( + error = %e, + id = picture.id, + "native VAAPI: a flushed picture could not be exported" + ), + } + } + // No conversion runs on this path, so this is the only place the planner's + // releases can reach the ledger — the same reason `show_existing_av1` applies + // them by hand. Without it a resumed stream finds every slot taken. + for id in &removed { + s.slots.release(*id); + } + s.sync_slot_bindings(); + out + } + + fn decode_h264(&mut self, au: &[u8]) -> Result<(Vec, bool)> { let plan = match &mut self.planner { Planner::H264(p) => p.plan_au(au).map_err(|e| anyhow!("{e:?}"))?, _ => unreachable!("dispatched on the planner's own arm"), @@ -1011,15 +1422,21 @@ impl NativeVaapiDecoder { shape, &mut self.generation, )?; - let free = s - .free_surface() + let (free, target, table) = s + .acquire_target() .ok_or_else(|| anyhow!("surface pool exhausted ({} surfaces)", s.surfaces.len()))?; - let target = s.surfaces[free]; - let table = s.surface_table(); let converted = pf_vaadec::plan_to_va(&plan, au, &mut s.slots, &table, target) .map_err(|e| anyhow!("{e}"))?; - bind_setup(s, plan.dpb.stored, Some(free)); + // Recorded HERE, with the picture that is decoding, rather than read again at + // display time: on a reordering stream the access unit that displays this + // picture is a later one and says something different (see [`PictureFacts`]). + let facts = PictureFacts { + keyframe: plan.picture.is_idr, + color: colour_of(&plan.picture.colour), + display: (s.shape.display_width, s.shape.display_height), + }; + bind_setup(s, plan.dpb.stored, Some(free), facts); let iq = Some(as_ptr(&converted.iq_matrix)); let slices = one_record_each(&converted.slices, &converted.slice_data)?; @@ -1033,30 +1450,28 @@ impl NativeVaapiDecoder { au, )?; - let display_size = (s.shape.display_width, s.shape.display_height); - let frame = finish( + let frames = finish( display, s, &plan.dpb.outputs, &plan.dpb.removed, damaged, - plan.picture.is_idr, - colour_of(&plan.picture.colour), - display_size, &mut self.recovery_request, &self.release_tx, )?; - Ok((frame, damaged)) + Ok((frames, damaged)) } - fn decode_h265(&mut self, au: &[u8]) -> Result<(Option, bool)> { + fn decode_h265(&mut self, au: &[u8]) -> Result<(Vec, bool)> { let plan = match &mut self.planner { Planner::H265(p) => match p.plan_au(au) { Ok(plan) => plan, // The contract pf-bitstream's h265 module docs record for this // wiring: a skipped RASL picture is an Ok-skip, never an error and // never a re-anchor. See [`Self::decode`]. - Err(pf_vaadec::PlanErrorH265::RaslSkipped { .. }) => return Ok((None, false)), + Err(pf_vaadec::PlanErrorH265::RaslSkipped { .. }) => { + return Ok((Vec::new(), false)) + } Err(e) => return Err(anyhow!("{e:?}")), }, _ => unreachable!("dispatched on the planner's own arm"), @@ -1087,15 +1502,20 @@ impl NativeVaapiDecoder { shape, &mut self.generation, )?; - let free = s - .free_surface() + let (free, target, table) = s + .acquire_target() .ok_or_else(|| anyhow!("surface pool exhausted ({} surfaces)", s.surfaces.len()))?; - let target = s.surfaces[free]; - let table = s.surface_table(); let converted = pf_vaadec::plan_to_va_h265(&plan, au, &mut s.slots, &table, target) .map_err(|e| anyhow!("{e}"))?; - bind_setup(s, plan.dpb.stored, Some(free)); + // This picture's own facts, not the facts of whichever later access unit + // bumps it out (see [`PictureFacts`]). + let facts = PictureFacts { + keyframe: plan.picture.is_idr, + color: colour_of(&plan.picture.colour), + display: (s.shape.display_width, s.shape.display_height), + }; + bind_setup(s, plan.dpb.stored, Some(free), facts); // The IQ matrix is submitted ONLY where the sequence codes scaling lists. // Handing the driver an all-zero matrix on a "use the defaults" stream is @@ -1115,20 +1535,16 @@ impl NativeVaapiDecoder { au, )?; - let display_size = (s.shape.display_width, s.shape.display_height); - let frame = finish( + let frames = finish( display, s, &plan.dpb.outputs, &plan.dpb.removed, damaged, - plan.picture.is_idr, - colour_of(&plan.picture.colour), - display_size, &mut self.recovery_request, &self.release_tx, )?; - Ok((frame, damaged)) + Ok((frames, damaged)) } /// One AV1 **temporal unit**: decode every frame in it, present at most one. @@ -1175,12 +1591,12 @@ impl NativeVaapiDecoder { /// access unit whose tile groups were lost. That refusal is handled in /// [`Self::frame_av1`] and binds no surface at all, so its picture can be neither /// exported nor predicted from. - fn decode_av1(&mut self, au: &[u8]) -> Result<(Option, bool)> { + fn decode_av1(&mut self, au: &[u8]) -> Result<(Vec, bool)> { let plans = match &mut self.planner { Planner::Av1(p) => p.plan_au(au).map_err(|e| anyhow!("{e}"))?, _ => unreachable!("dispatched on the planner's own arm"), }; - let mut shown = None; + let mut shown: Vec = Vec::new(); let mut damaged_unit = false; for plan in &plans { let damaged = plan @@ -1191,9 +1607,7 @@ impl NativeVaapiDecoder { if !plan.warnings.is_empty() { tracing::debug!(warnings = ?plan.warnings, damaged, "native VAAPI AV1 plan warnings"); } - if let Some(frame) = self.frame_av1(au, plan, damaged)? { - shown = Some(frame); - } + shown.extend(self.frame_av1(au, plan, damaged)?); } if damaged_unit { // A frame may already have been exported before a LATER frame of the @@ -1202,7 +1616,7 @@ impl NativeVaapiDecoder { // the surface to the free list, which is exactly what an unshown picture // should do. drop(shown); - return Ok((None, true)); + return Ok((Vec::new(), true)); } Ok((shown, false)) } @@ -1220,7 +1634,7 @@ impl NativeVaapiDecoder { au: &[u8], plan: &pf_vaadec::AuPlanAv1, damaged: bool, - ) -> Result> { + ) -> Result> { // `show_existing_frame` decodes nothing at all: it re-displays a picture some // earlier hidden frame put in a reference slot. if plan.dpb.stored.is_none() { @@ -1237,11 +1651,34 @@ impl NativeVaapiDecoder { shape, &mut self.generation, )?; - let free = s - .free_surface() + // AV1's display region is the RENDER size, not the coded size — and it is a + // per-FRAME value, so it cannot live in the session shape the way a + // conformance window does. Which is also why it belongs to the PICTURE: a + // frame held back in the deliverable queue must still be shown at the region + // ITS header asked for, not the newest one's. + // + // ⚠ CLAMPED to the decoded picture. AV1 5.9.6 puts no upper bound on the + // render size — a stream may legally ask to be shown at more than it coded — + // and an unclamped crop would hand the presenter a region larger than the + // surface. The same clamp is in the Vulkan and D3D11 rungs. + // + // ⚠ Treated as a CROP, which is what both other native rungs do. libavcodec + // instead keeps the frame at `upscaled_width` x `frame_height` and expresses + // the render size as a sample aspect RATIO, so on a stream where the two + // differ this rung shows less picture than libavcodec would. No + // punktfunk host emits such a stream; the choice is here so the three native + // rungs answer alike, not because it is settled. + let facts = PictureFacts { + keyframe: plan.picture.is_key, + color: colour_of(&plan.picture.colour), + display: ( + plan.picture.render_width.min(plan.picture.upscaled_width), + plan.picture.render_height.min(plan.picture.frame_height), + ), + }; + let (free, target, table) = s + .acquire_target() .ok_or_else(|| anyhow!("surface pool exhausted ({} surfaces)", s.surfaces.len()))?; - let target = s.surfaces[free]; - let table = s.surface_table(); let converted = match pf_vaadec::plan_to_va_av1(plan, au, &mut s.slots, &table, target) { Ok(converted) => converted, Err(e) => { @@ -1252,7 +1689,7 @@ impl NativeVaapiDecoder { // because it is also correct for the refusals that fire before any // mutation: there is no slot to clear and no surface to bind either // way. - bind_setup(s, plan.dpb.stored, None); + bind_setup(s, plan.dpb.stored, None, facts); // A lost tile group on a plan the planner ALREADY called damaged is // concealment, not a defect: the access unit simply did not carry the // tiles its frame header announced, which is what one dropped packet @@ -1272,19 +1709,10 @@ impl NativeVaapiDecoder { &plan.dpb.outputs, &plan.dpb.removed, true, - plan.picture.is_key, - colour_of(&plan.picture.colour), - // Unread — `finish` returns before it looks at the display - // region when `damaged` — but written the same way as the - // submitting path below, so the two cannot drift apart. - ( - plan.picture.render_width.min(plan.picture.upscaled_width), - plan.picture.render_height.min(plan.picture.frame_height), - ), &mut self.recovery_request, &self.release_tx, )?; - return Ok(None); + return Ok(Vec::new()); } return Err(anyhow!("{e}")); } @@ -1296,7 +1724,7 @@ impl NativeVaapiDecoder { // the surface and only the pending-output claim keeps it out of the free // list. (`DecodePlanVaAv1::setup_slot` is `None` there; it is not consulted // here for exactly that reason.) - bind_setup(s, plan.dpb.stored, Some(free)); + bind_setup(s, plan.dpb.stored, Some(free), facts); if converted.substituted_refs != 0 { tracing::debug!( @@ -1327,34 +1755,12 @@ impl NativeVaapiDecoder { au, )?; - // AV1's display region is the RENDER size, not the coded size — and it is a - // per-FRAME value, so it cannot live in the session shape the way a - // conformance window does. - // - // ⚠ CLAMPED to the decoded picture. AV1 5.9.6 puts no upper bound on the - // render size — a stream may legally ask to be shown at more than it coded — - // and an unclamped crop would hand the presenter a region larger than the - // surface. The same clamp is in the Vulkan and D3D11 rungs. - // - // ⚠ Treated as a CROP, which is what both other native rungs do. libavcodec - // instead keeps the frame at `upscaled_width` x `frame_height` and expresses - // the render size as a sample aspect RATIO, so on a stream where the two - // differ this rung shows less picture than libavcodec would. No - // punktfunk host emits such a stream; the choice is here so the three native - // rungs answer alike, not because it is settled. - let display_size = ( - plan.picture.render_width.min(plan.picture.upscaled_width), - plan.picture.render_height.min(plan.picture.frame_height), - ); - let frame = finish( + let frames = finish( display, s, &plan.dpb.outputs, &plan.dpb.removed, damaged, - plan.picture.is_key, - colour_of(&plan.picture.colour), - display_size, &mut self.recovery_request, &self.release_tx, )?; @@ -1370,9 +1776,9 @@ impl NativeVaapiDecoder { // be a session that dies of "pool exhausted" some minutes later with nothing // pointing back here. if converted.setup_slot.is_none() && !plan.dpb.outputs.contains(&converted.setup_id) { - s.pending.retain(|(id, _)| *id != converted.setup_id); + s.pending.retain(|p| p.id != converted.setup_id); } - Ok(frame) + Ok(frames) } /// A `show_existing_frame` access unit: export a surface the pool already holds. @@ -1393,14 +1799,14 @@ impl NativeVaapiDecoder { &mut self, plan: &pf_vaadec::AuPlanAv1, damaged: bool, - ) -> Result> { + ) -> Result> { let Self { display, session, .. } = self; // Nothing has decoded yet: the unit is already concealed (the planner // reported `MissingShowExisting`) and there is no session to look in. let Some(s) = session.as_mut() else { - return Ok(None); + return Ok(Vec::new()); }; // Showing a KEY frame this way resets the whole reference store (AV1 7.20), // so the plan's removals are real and this rung's ledger has to follow them — @@ -1410,19 +1816,18 @@ impl NativeVaapiDecoder { s.slots.release(id); } s.sync_slot_bindings(); - let display_size = ( - plan.picture.render_width.min(plan.picture.upscaled_width), - plan.picture.render_height.min(plan.picture.frame_height), - ); + // ⚠ No `PictureFacts` are built here, and that is the point of recording them + // at decode time: the picture this unit displays was decoded by an EARLIER + // hidden frame and already carries its own keyframe flag, colour description + // and render region. The display-only header the vendored parser restores + // from the reference says the same thing, but taking it from the pending + // entry means `show_existing_frame` needs no per-surface facts table at all. finish( display, s, &plan.dpb.outputs, &plan.dpb.removed, damaged, - plan.picture.is_key, - colour_of(&plan.picture.colour), - display_size, &mut self.recovery_request, &self.release_tx, ) @@ -1431,6 +1836,19 @@ impl NativeVaapiDecoder { impl Drop for NativeVaapiDecoder { fn drop(&mut self) { + // Teardown is the only end of stream this rung can observe (see + // [`NativeVaapiDecoder::flush`]). Nothing here can be presented, so the point + // is to RELEASE: the deliverable queue's surfaces and the DPB's, before + // `Session::destroy` pulls the pool out from under them. Same walk the + // conformance harness drives, deliberately — a teardown path nothing exercises + // is a teardown path nothing checks. + let tail = self.flush().len(); + if tail > 0 { + tracing::debug!( + count = tail, + "native VAAPI: released frames never shown at teardown" + ); + } if self.stale_releases > 0 { // Not an error — a renegotiated session's frames come home to a pool // that no longer exists — but a count worth seeing, because the only @@ -1623,14 +2041,18 @@ fn ensure_session<'a>( /// read back as `VA_INVALID_ID`, which the conversion then substitutes with a live /// surface. Nothing is pushed to `pending` either: an undecoded surface must never be /// exportable. -fn bind_setup(s: &mut Session, stored: Option, surface: Option) { +/// +/// `facts` travels with the picture from here (see [`PictureFacts`]) and is read back +/// by [`settle`] when the picture is finally displayed — which on a reordering stream +/// is a different access unit saying different things. +fn bind_setup(s: &mut Session, stored: Option, surface: Option, facts: PictureFacts) { s.sync_slot_bindings(); let Some(id) = stored else { return }; if let Some(slot) = s.slots.slot_of(id) { s.slot_surface[usize::from(slot)] = surface; } if let Some(surface) = surface { - s.pending.push((id, surface)); + s.pending.push(PendingPicture { id, surface, facts }); } } @@ -1805,7 +2227,14 @@ fn submit( result } -/// Turn this AU's OUTPUT list into at most one shipped frame. +/// Claim every picture this access unit displays and retire everything it displaced — +/// the PURE half of [`finish`], and the half a test can drive without a device. +/// +/// Returns the claimed pictures **in display order**, each carrying the facts recorded +/// when it decoded ([`PictureFacts`]). A claimed picture is no longer in `pending`, so +/// until the caller either ships it (which marks its surface `held`) or drops it, the +/// only thing keeping its surface off the free list is a DPB slot it may no longer +/// have — which is why claiming and shipping happen in one breath. /// /// Display order, not decode order. `plan.dpb.outputs` is what the planner says is /// ready to be shown and in what order, and the surface for each is looked up by @@ -1814,9 +2243,15 @@ fn submit( /// order; that is a known finding on a rung that blits its output away, and there /// was no reason to inherit it here where the display-order queue costs a lookup.) /// -/// Newest wins, which is the same rule the FFmpeg VAAPI rung applies inside its -/// receive loop: on a live stream a picture already superseded is not worth a frame -/// interval. Superseded outputs are released rather than exported. +/// ⚠ **Every output, not the last one.** Until 2026-08-07 this took `outputs.last()` +/// and RETIRED the rest unshown — "newest wins", borrowed from the FFmpeg VAAPI rung's +/// receive loop, where it is a statement about a live stream that has already fallen +/// behind rather than about a decoder's own reorder buffer. Applied here it discarded +/// pictures nobody had yet had the chance to fall behind on: a bump is how a reordering +/// stream delivers, and an IDR draining a full DPB bumps the whole buffer at once. It +/// cost the vendored H.264 vector 18 frames at three access units. The caller queues +/// what it cannot hand over at once ([`NativeVaapiDecoder::deliverable`]); dropping is +/// that queue's decision to make, at its bound, with a counter and a log line. /// /// The retirement rule is `pf_vkdecode`'s `settle_dpb`, reimplemented here over this /// rung's flat pending list rather than reasoned out again, because both halves of it @@ -1829,59 +2264,40 @@ fn submit( /// * **An output naming no pending picture is a TRACE, not an error.** Ids planned /// before this decoder existed, or dropped across a session rebuild, are /// display-order gaps. -#[allow(clippy::too_many_arguments)] -fn finish( - d: &Display, - s: &mut Session, - outputs: &[u64], - removed: &[u64], - damaged: bool, - keyframe: bool, - color: ColorDesc, - // The DISPLAY region for this picture. A parameter rather than a read of - // `s.shape` because AV1's is per-FRAME: its render size may change without a key - // frame, so it cannot live in the shape that rebuilds the session. - display: (u32, u32), - recovery_request: &mut bool, - tx: &mpsc::Sender, -) -> Result> { - // A concealed picture is not shown: it was decoded from a substitute reference, - // so shipping it paints the substitution on screen. Nothing this AU output is - // shown, the pump is asked to re-anchor, and the caller records the damage. - let shown = if damaged { - None - } else { - outputs.last().copied() - }; - // OUTPUTS FIRST, and the shown one is taken out before anything else runs. - // A picture is normally output and removed by the SAME access unit — that is - // what bumping is — so retiring `removed` before claiming the frame would - // discard the very picture about to be displayed, on essentially every AU. - let claimed = shown.and_then(|id| { - let found = s.pending.iter().position(|(pid, _)| *pid == id); - if found.is_none() { - tracing::trace!(id, "output id without a pending picture"); - } - found.map(|index| s.pending.remove(index).1) - }); +/// +/// OUTPUTS FIRST, and they are taken out before anything else runs: a picture is +/// normally output and removed by the SAME access unit — that is what bumping is — so +/// retiring `removed` before claiming would discard the very pictures about to be +/// displayed, on essentially every access unit. +fn settle(s: &mut Session, outputs: &[u64], removed: &[u64]) -> Vec { + let mut claimed = Vec::with_capacity(outputs.len()); for id in outputs { - if Some(*id) != shown { - s.pending.retain(|(pid, _)| pid != id); + match s.pending.iter().position(|p| p.id == *id) { + Some(index) => claimed.push(s.pending.remove(index)), + None => tracing::trace!(id, "output id without a pending picture"), } } // Whatever left the DPB is retired from the pending list whether or not it was // ever output. Its SURFACE only becomes free if nothing else holds it — a // reference still bound to a slot, or a frame the consumer has, stays put. for id in removed { - s.pending.retain(|(pid, _)| pid != id); + s.pending.retain(|p| p.id != *id); } - if damaged { - *recovery_request = true; - return Ok(None); - } - let Some(surface_index) = claimed else { - return Ok(None); - }; + claimed +} + +/// Export one claimed picture as the dmabuf frame the presenter imports, and take the +/// consumer's hold on its surface. +/// +/// Split out of [`finish`] so the flush path ships by exactly the same walk rather than +/// by a second one written to match. +fn ship( + d: &Display, + s: &mut Session, + picture: PendingPicture, + tx: &mpsc::Sender, +) -> Result { + let surface_index = picture.surface; let surface = s.surfaces[surface_index]; // OWNED from here. `export` wraps the descriptor's fds the moment the call @@ -1915,17 +2331,17 @@ fn finish( stride: p.stride, }) .collect(); - Ok(Some(DmabufFrame { - // The DISPLAY region. The surface is allocated at the coded size and is - // taller/wider than the picture; handing over the coded size would show the - // codec's granule padding. - width: display.0, - height: display.1, + Ok(DmabufFrame { + // The DISPLAY region THIS picture asked for. The surface is allocated at the + // coded size and is taller/wider than the picture; handing over the coded size + // would show the codec's granule padding. + width: picture.facts.display.0, + height: picture.facts.display.1, fourcc: exported.fourcc, modifier: exported.modifier, planes, - color, - keyframe, + color: picture.facts.color, + keyframe: picture.facts.keyframe, guard: DrmFrameGuard(VaFrameGuard { _fds: fds, tx: tx.clone(), @@ -1934,7 +2350,44 @@ fn finish( generation: s.generation, }, }), - })) + }) +} + +/// Turn this access unit's OUTPUT list into shipped frames, in display order. +/// +/// [`settle`] does the ledger, [`ship`] does the export; this is the two together plus +/// the concealment rule that decides whether anything is shown at all. +/// +/// A refusal part-way through ships nothing: the frames already exported are dropped by +/// the `?`, and their guards close the fds and hand the surfaces straight back. The +/// pictures not yet reached are dropped too — [`settle`] already took them out of +/// `pending`, so nothing claims their surfaces and they return to the free list on the +/// spot. Which is the rule every rung in this program keeps: nothing from a refused +/// access unit reaches the screen. +fn finish( + d: &Display, + s: &mut Session, + outputs: &[u64], + removed: &[u64], + damaged: bool, + recovery_request: &mut bool, + tx: &mpsc::Sender, +) -> Result> { + let claimed = settle(s, outputs, removed); + // A concealed picture is not shown: it was decoded from a substitute reference, + // so shipping it paints the substitution on screen. Nothing this AU output is + // shown, the pump is asked to re-anchor, and the caller records the damage. The + // claimed pictures simply drop here — never exported, never held, so their + // surfaces are free the moment this returns. + if damaged { + *recovery_request = true; + return Ok(Vec::new()); + } + let mut frames = Vec::with_capacity(claimed.len()); + for picture in claimed { + frames.push(ship(d, s, picture, tx)?); + } + Ok(frames) } /// Wait for the decode and export the surface as DRM-PRIME dmabufs. @@ -2031,6 +2484,27 @@ mod tests { } } + /// Facts a test does not care about. Everything that DOES care about them builds + /// its own, so a shared default can never be what makes an assertion pass. + const PLAIN: PictureFacts = PictureFacts { + keyframe: false, + color: ColorDesc { + primaries: 1, + transfer: 1, + matrix: 1, + full_range: false, + }, + display: (64, 64), + }; + + fn pending(id: u64, surface: usize) -> PendingPicture { + PendingPicture { + id, + surface, + facts: PLAIN, + } + } + /// The whole rule, in one test: a surface is free only when NOTHING claims it, /// and the three claims end at different moments. #[test] @@ -2042,9 +2516,11 @@ mod tests { "a fresh pool starts at the front" ); - // 0: a live DPB reference. 1: decoded, still owing an output. 2: on screen. + // 0: a live DPB reference. 1: decoded, still owing an output. 2: on screen — + // or waiting in the deliverable queue, which is the same claim (see + // [`Session::free_surface`]). s.slot_surface[0] = Some(0); - s.pending.push((7, 1)); + s.pending.push(pending(7, 1)); s.held[2] = true; assert_eq!( s.free_surface(), @@ -2129,6 +2605,138 @@ mod tests { ); } + /// The decode target is never a surface the reference table names — swept over + /// every binding state a small pool can be in. + /// + /// This rung's exemption from the aliasing defect the D3D11VA and Vulkan rungs had + /// to defer their way out of (module docs), stated as the one thing it actually + /// rests on. `pf-vaadec` proves the conversion can only name surfaces out of the + /// table it is handed; this proves the table and the target cannot overlap. + /// + /// Swept rather than exemplified because the claim is structural — a free surface + /// is by definition one no slot binds, and the table is exactly what the slots bind + /// — so it should hold in states an ordinary run never reaches, and a sweep is what + /// says so. The `held` and `pending` masks are varied too even though they can only + /// ever REMOVE candidates from the free list: a future claim that could add one + /// back is exactly what this would catch. + #[test] + fn the_decode_target_can_never_be_a_surface_the_reference_table_names() { + const SURFACES: usize = 4; + const SLOTS: usize = 3; + let choices: Vec> = std::iter::once(None) + .chain((0..SURFACES).map(Some)) + .collect(); + + let (mut states, mut with_a_target, mut exhausted) = (0usize, 0usize, 0usize); + for a in &choices { + for b in &choices { + for c in &choices { + let bound = [*a, *b, *c]; + // Two slots binding ONE surface is not a state the pool can reach — + // `bind_setup` only ever binds a surface nothing else claims — and + // asserting about it would be asserting about a defect elsewhere. + let mut distinct: Vec = bound.iter().flatten().copied().collect(); + let claimed = distinct.len(); + distinct.sort_unstable(); + distinct.dedup(); + if distinct.len() != claimed { + continue; + } + for held_mask in 0..(1u32 << SURFACES) { + for pending_mask in 0..(1u32 << SURFACES) { + let mut s = session(SURFACES, SLOTS); + s.slot_surface = bound.to_vec(); + s.held = (0..SURFACES).map(|i| held_mask >> i & 1 == 1).collect(); + s.pending = (0..SURFACES) + .filter(|i| pending_mask >> i & 1 == 1) + .map(|i| pending(100 + i as u64, i)) + .collect(); + states += 1; + + let Some((index, target, table)) = s.acquire_target() else { + exhausted += 1; + continue; + }; + with_a_target += 1; + assert_eq!( + target, s.surfaces[index], + "the target must be the pool's surface at the index it \ + returned, or the caller binds one and submits another" + ); + assert_eq!(table.len(), SLOTS, "one table entry per slot"); + assert!( + !table.contains(&target), + "bindings {bound:?}, held {held_mask:#06b}, pending \ + {pending_mask:#06b}: the decode target {target:#x} is \ + in the reference table {table:x?} — every submission \ + built from that pair decodes into a surface it may be \ + predicting from" + ); + } + } + } + } + } + // The sweep has to reach both answers, or it is asserting about one branch. + assert!(states > 1000, "only {states} states swept"); + assert!(with_a_target > 0 && exhausted > 0); + } + + /// The order the rung must NOT be written in, and the reason + /// [`Session::acquire_target`] hands the target and the table back together. + /// + /// The exemption above is not a property of the pool alone: it needs the target and + /// the table to come from ONE snapshot. Split them and this rung acquires the + /// D3D11VA/Vulkan defect exactly — because the table must be the PRE-removal one + /// (a reference an access unit names can be a picture the same access unit evicts, + /// which on a punktfunk host's own low-delay H.264 is 117 access units in 120), + /// while a free list consulted after those removals offers precisely the displaced + /// picture's surface. + #[test] + fn taking_the_free_surface_after_the_removals_would_hand_out_a_referenced_surface() { + let mut s = session(4, 3); + + // Two decoded reference pictures, each in its own surface, both already + // displayed and returned by the presenter — so only the SLOT binding keeps + // their surfaces off the free list. That is the steady state of a low-delay + // stream, where a picture is output by its own access unit and evicted by the + // sliding window several units later. + s.slots.assign(11).expect("a free slot"); + bind_setup(&mut s, Some(11), Some(0), PLAIN); + s.slots.assign(12).expect("a free slot"); + bind_setup(&mut s, Some(12), Some(1), PLAIN); + s.pending.clear(); + + // What the conversion resolves its references through, taken BEFORE this access + // unit's removals — which is not a choice, it is where the references are. + let table = s.surface_table(); + assert!( + table.contains(&s.surfaces[0]), + "picture 11's surface must still be a resolvable reference" + ); + + // The order the rung is written in: one snapshot, and the target cannot be in + // the table it came with. + let (_, target, same_table) = s.acquire_target().expect("the pool has spares"); + assert_eq!( + same_table, table, + "acquire_target must not re-derive the table" + ); + assert!(!table.contains(&target)); + + // The defect: the conversion applies the removal, the bindings follow it, and + // only THEN is the free list consulted. + s.slots.release(11); + s.sync_slot_bindings(); + let late = s.free_surface().expect("the pool has spares"); + assert_eq!( + s.surfaces[late], table[0], + "the late free list offers the surface of the picture this access unit just \ + displaced, and the pre-removal table still names it as a reference — \ + decode into that and the driver predicts from the picture it is writing" + ); + } + /// A picture the conversion REFUSED binds no surface — so nothing can show it and /// nothing can predict from it. /// @@ -2145,17 +2753,17 @@ mod tests { // Picture 11 decoded into surface 0 and took slot 0. s.slots.assign(11).expect("a free slot"); - bind_setup(&mut s, Some(11), Some(0)); + bind_setup(&mut s, Some(11), Some(0), PLAIN); assert_eq!(s.slot_surface[0], Some(0)); assert_eq!(s.surface_table()[0], s.surfaces[0]); - assert_eq!(s.pending, vec![(11, 0)]); + assert_eq!(s.pending, vec![pending(11, 0)]); // Picture 12's access unit lost its tile groups. The conversion released 11, // handed 12 the slot it just gave back — the routine case, not a contrived one // — and then refused. s.slots.release(11); assert_eq!(s.slots.assign(12).expect("the slot 11 gave back"), 0); - bind_setup(&mut s, Some(12), None); + bind_setup(&mut s, Some(12), None, PLAIN); assert_eq!( s.slot_surface[0], None, @@ -2168,7 +2776,7 @@ mod tests { "and the table the conversion reads must say so, so it can substitute" ); assert!( - !s.pending.iter().any(|(id, _)| *id == 12), + !s.pending.iter().any(|p| p.id == 12), "an undecoded picture owes no output — a pending entry is what would let \ a later show_existing_frame export a surface the driver never wrote" ); @@ -2179,6 +2787,230 @@ mod tests { assert_eq!(s.slots.slot_of(12), Some(0)); } + /// **Every** picture an access unit displays is claimed, in the planner's display + /// order — the defect this rung carried until 2026-08-07, with the behaviour it + /// replaced written out beside it. + /// + /// [`settle`] is the pure half of [`finish`] precisely so this can be asserted with + /// no libva, no device and no surfaces: it is a walk over a list. + #[test] + fn settle_claims_every_output_in_display_order_not_only_the_last() { + let mut s = session(8, 5); + // Four pictures decoded and buffered for reorder, each in its own surface — + // the state a reordering stream is in when an IDR drains the buffer. + for (index, id) in [11u64, 12, 13, 14].iter().enumerate() { + s.pending.push(pending(*id, index)); + } + // Display order 13, 11, 14, 12: deliberately neither decode order nor sorted, + // because the planner's list IS the display order and this rung must present + // in it rather than re-derive one. + let outputs = [13u64, 11, 14, 12]; + let claimed = settle(&mut s, &outputs, &outputs); + + assert_eq!( + claimed.iter().map(|p| p.id).collect::>(), + outputs, + "every bumped picture must come back, in the order the planner listed them" + ); + assert_eq!( + claimed.iter().map(|p| p.surface).collect::>(), + vec![2, 0, 3, 1], + "and each must resolve to ITS OWN surface, not to its position in the list" + ); + assert!( + s.pending.is_empty(), + "a claimed picture no longer owes an output" + ); + + // ⚠ The counterfactual. What this used to do, in one line, run against the same + // access unit: ship `outputs.last()` and retire the other three unshown. + let old_rule: Vec = outputs.last().copied().into_iter().collect(); + assert_eq!(old_rule, vec![12]); + assert_eq!( + claimed.len() - old_rule.len(), + 3, + "the old rule dropped three of these four pictures — on the vendored H.264 \ + vector that is 18 frames at three access units, and nothing counted them" + ); + } + + /// The two retirement rules, which are the reason a pool sized for a stream does + /// not walk into exhaustion anyway. + #[test] + fn settle_retires_what_left_the_dpb_unshown_and_traces_an_output_it_cannot_place() { + let mut s = session(4, 3); + s.pending.push(pending(11, 0)); + s.pending.push(pending(12, 1)); + + // Picture 12 leaves the DPB without ever being output — `no_output_of_prior_pics` + // at an IDR, the everyday case. A pending list that only shrank on OUTPUT would + // hold its surface for the rest of the session. + let claimed = settle(&mut s, &[11], &[11, 12]); + assert_eq!(claimed.iter().map(|p| p.id).collect::>(), vec![11]); + assert!(s.pending.is_empty(), "12 was retired unshown"); + + // An output naming no pending picture is a display-order gap, not an error: + // ids planned before this decoder existed, or dropped across a rebuild. + assert!(settle(&mut s, &[99], &[]).is_empty()); + } + + /// A displayed picture carries **its own** facts, not those of whichever access + /// unit happens to bump it out. + /// + /// All three fields fail differently and all three were wrong: `keyframe` is the + /// pump's post-loss re-anchor signal, `color` decides whether PQ content is drawn + /// as BT.709, and `display` is AV1's per-frame render region. + #[test] + fn a_displayed_picture_carries_its_own_facts_not_its_display_units() { + /// What the rung stamped until 2026-08-07: the ACCESS UNIT's flag, whatever + /// picture the bump happened to display. + fn old_label(bumping_au_is_idr: bool) -> bool { + bumping_au_is_idr + } + + let mut s = session(4, 3); + let idr = PictureFacts { + keyframe: true, + color: ColorDesc { + primaries: 9, + transfer: 16, + matrix: 9, + full_range: false, + }, + display: (1920, 1080), + }; + let trail = PictureFacts { + keyframe: false, + color: ColorDesc { + primaries: 1, + transfer: 1, + matrix: 1, + full_range: false, + }, + display: (1280, 720), + }; + s.pending.push(PendingPicture { + id: 11, + surface: 0, + facts: idr, + }); + s.pending.push(PendingPicture { + id: 12, + surface: 1, + facts: trail, + }); + + let claimed = settle(&mut s, &[11, 12], &[11, 12]); + assert_eq!(claimed[0].facts, idr); + assert_eq!(claimed[1].facts, trail); + + // ⚠ The counterfactual, and it fails BOTH ways round. + assert_ne!( + old_label(false), + claimed[0].facts.keyframe, + "the IDR is bumped out by an ordinary TRAILING access unit several units \ + later, so the old rule flagged the pump's one re-anchor frame as not a \ + keyframe — measured on all three hardware legs' first delivered frame" + ); + assert_ne!( + old_label(true), + claimed[1].facts.keyframe, + "and the access unit that DRAINS the DPB at a later IDR flagged every old \ + trailing picture draining with it as a keyframe — a re-anchor on a frame \ + that is not one" + ); + assert_ne!( + claimed[0].facts.color, claimed[1].facts.color, + "the same access unit displays pictures decoded under different SPS/VUIs \ + when the host switches an HDR desktop to PQ in-band" + ); + assert_ne!( + claimed[0].facts.display, claimed[1].facts.display, + "and AV1's render region is a per-FRAME value, so a queued frame shown two \ + units later would be cropped to whatever the newest frame asked for" + ); + } + + /// A frame with no fds — every field the queue's bound cares about, and nothing + /// that needs a device. Its guard is real, so dropping it really does release. + fn queued_frame(tx: &mpsc::Sender, surface: usize) -> DmabufFrame { + DmabufFrame { + width: 64, + height: 64, + fourcc: pf_vaadec::VA_FOURCC_NV12, + modifier: 0, + planes: Vec::new(), + color: PLAIN.color, + keyframe: false, + guard: DrmFrameGuard(VaFrameGuard { + _fds: Vec::new(), + tx: tx.clone(), + release: VaRelease { + surface, + generation: 1, + }, + }), + } + } + + /// The queue drops its OLDEST rather than pinning surfaces forever — and a dropped + /// frame's surface really does come back. + /// + /// The second half is what makes this a surface-lifetime test rather than a + /// bookkeeping one: every frame the queue holds is a `held` surface, so a bound + /// that dropped frames without releasing them would trade one leak for another. + #[test] + fn the_deliverable_queue_drops_its_oldest_and_frees_the_surface_it_held() { + let (tx, rx) = mpsc::channel(); + let mut s = session(8, 5); + let mut queue: std::collections::VecDeque = + (0..5).map(|i| queued_frame(&tx, i)).collect(); + for i in 0..5 { + s.held[i] = true; + } + + let dropped = trim_deliverable(&mut queue, 3); + assert_eq!( + dropped + .iter() + .map(|f| f.guard.0.release.surface) + .collect::>(), + vec![0, 1], + "the OLDEST two go: dropping the newest would keep the stalest picture and \ + present the stream in ever-lagging order" + ); + assert_eq!( + queue + .iter() + .map(|f| f.guard.0.release.surface) + .collect::>(), + vec![2, 3, 4], + "and what survives keeps display order" + ); + + // The surfaces come back only when the dropped frames actually drop. + let mut stale = 0u64; + drain_releases_into(&rx, Some(&mut s), &mut stale); + assert!( + s.held[0] && s.held[1], + "a frame still owned holds its surface — the trim returns them so the \ + caller can count them, and the release is the drop" + ); + drop(dropped); + drain_releases_into(&rx, Some(&mut s), &mut stale); + assert!( + !s.held[0] && !s.held[1], + "dropping a trimmed frame returns its surface by exactly the path a \ + presented frame takes" + ); + assert_eq!(stale, 0, "and none of it is a stale-generation token"); + + // Idempotent, and a bound of 0 drains rather than loops. + assert!(trim_deliverable(&mut queue, 3).is_empty()); + assert_eq!(trim_deliverable(&mut queue, 0).len(), 3); + assert!(trim_deliverable(&mut queue, 0).is_empty()); + } + /// A conformance window with a non-zero ORIGIN is refused, not cropped from the /// wrong corner: nothing downstream carries an origin. #[test] @@ -2423,6 +3255,12 @@ mod tests { ("H.264 High", pf_vaadec::config::VA_PROFILE_H264_HIGH), ("HEVC Main", pf_vaadec::config::VA_PROFILE_HEVC_MAIN), ("HEVC Main 10", pf_vaadec::config::VA_PROFILE_HEVC_MAIN10), + // AV1 was MISSING from this loop until 2026-08-07, which is part + // of why the rung's evidence row could say "never decoded a frame" + // for so long without anyone noticing what had not been asked. + // `profile_for` maps both 8- and 10-bit AV1 4:2:0 onto Profile 0. + ("AV1 Profile 0", pf_vaadec::config::VA_PROFILE_AV1_PROFILE0), + ("AV1 Profile 1", pf_vaadec::config::VA_PROFILE_AV1_PROFILE1), ] { match d.require_entrypoint(profile) { Ok(()) => eprintln!(" {name}: VLD decode"), @@ -2438,4 +3276,2890 @@ mod tests { nodes.len() ); } + + /// The vendored AV1 vector, as IVF: 320x240 Main 4:2:0 8-bit, 250 temporal units + /// carrying 274 coded frames (24 units carry two, and those extras are HIDDEN — + /// decoded, referenced, never shown), so **250 frames are displayed**. The same + /// file `pf-vkdecode`'s Vulkan parity leg and `video_d3d11_native`'s D3D11VA leg + /// walk, so a count that disagrees with 250 is this rung's problem, not the + /// vector's. + pub(super) const AV1_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1" + ); + + /// One temporal unit per IVF packet: 32 bytes of `DKIF` header, then + /// `[u32 size][u64 pts][size bytes]`. Hand-rolled because `pf-client-core` does + /// not depend on the vendored parser crate — the same reason and the same walk as + /// `video_d3d11_native`'s `split_ivf`, and kept honest by the unit count asserted + /// at the top of the test below. + pub(super) fn split_ivf(stream: &[u8]) -> Vec<&[u8]> { + assert_eq!(&stream[0..4], b"DKIF", "the AV1 vector must be an IVF file"); + let header = usize::from(u16::from_le_bytes([stream[6], stream[7]])); + let mut out = Vec::new(); + let mut at = header; + while at + 12 <= stream.len() { + let size = + u32::from_le_bytes(stream[at..at + 4].try_into().expect("four bytes")) as usize; + at += 12; + assert!( + at + size <= stream.len(), + "an IVF frame header claims {size} bytes past the end of the file" + ); + out.push(&stream[at..at + size]); + at += size; + } + out + } + + /// Does this machine's VAAPI actually DECODE AV1 — the question the evidence table + /// has answered "no hardware has ever tried" since M6. + /// + /// A DECODE measurement, not frame-hash parity: it asserts that every temporal unit + /// is accepted, that the expected number of frames comes back, and that each one is + /// a real exported surface of the right shape. It says nothing about the PIXELS. + /// + /// That used to be all this rung could claim — it hands out a **DRM-PRIME dmabuf** + /// whose memory the driver tiles, so there was no CPU-readable image to hash. The + /// `parity` module below adds one, test-only, and + /// `parity::av1_every_delivered_frame_hashes_bit_identical_to_libavcodec` is the leg + /// that checks the pixels. This one is kept because it is the cheaper question and + /// it fails FIRST: a rung that stopped decoding at all should say so without + /// waiting for 250 hashes. + /// + /// Fails loudly rather than skipping when the device has no AV1 entry point: it is + /// `#[ignore]`d, so it only runs when someone deliberately points it at a box that + /// is supposed to have one, and a silent pass there is the invisible-failure mode + /// this whole program exists to end. + #[test] + #[ignore = "needs a machine with a libva runtime and an AV1 VLD entry point"] + fn av1_decodes_the_vendored_vector_on_this_machines_vaapi() { + let units = split_ivf(AV1_25FPS); + assert_eq!( + units.len(), + 250, + "the vendored AV1 vector is 250 temporal units" + ); + + let mut decoder = NativeVaapiDecoder::new(pf_vaadec::Codec::Av1, StreamFormat::SDR_420_8) + .expect("this box is supposed to have a VAAPI AV1 decode entry point"); + eprintln!("VAAPI AV1 rung constructed: {}", decoder.name()); + + let mut delivered = 0usize; + let mut first: Option<(u32, u32, u32, u64)> = None; + for (index, unit) in units.iter().enumerate() { + match decoder.decode(unit) { + Ok(Some(frame)) => { + assert!( + !frame.planes.is_empty(), + "unit {index}: a delivered frame exported no dmabuf planes" + ); + if first.is_none() { + assert!( + frame.keyframe, + "the vector opens on a keyframe, so the first delivered \ + frame must be flagged as one" + ); + first = Some((frame.width, frame.height, frame.fourcc, frame.modifier)); + } + delivered += 1; + } + Ok(None) => {} + Err(e) => panic!("unit {index}: VAAPI AV1 decode failed: {e:#}"), + } + } + // AV1 buffers no output between temporal units — it shows at most one frame per + // unit and `Av1Planner` has no `flush` to call — so its tail is empty by + // construction. Asserted rather than assumed, because the same call on the + // H.26x legs hands back seven frames. + assert!( + decoder.flush().is_empty(), + "AV1 strands nothing in the DPB: every temporal unit's shown frame is \ + delivered by the unit itself" + ); + + let (w, h, fourcc, modifier) = first.expect("not one frame came back"); + eprintln!( + "VAAPI AV1: {delivered} frames delivered, first {w}x{h} \ + fourcc={:?} modifier={modifier:#x}", + std::str::from_utf8(&fourcc.to_le_bytes()).unwrap_or("?") + ); + assert_eq!((w, h), (320, 240), "the vector is 320x240"); + assert_eq!( + delivered, 250, + "the vector displays 250 frames (274 coded, 24 hidden)" + ); + } + + // --------------------------------------------------------------------- + // H.264 / H.265 — the two legs that had never decoded a frame anywhere + // --------------------------------------------------------------------- + + /// The vendored H.264 vector: **250 access units** of 320x240 High 4:2:0 8-bit, + /// TWO slice NALUs per picture (500 slice NALs over 250 AUs, 4 IDRs). The same + /// file, at the same relative path, that `pf-vkdecode`'s Vulkan legs and + /// `video_d3d11_native`'s D3D11VA leg decode — so a count that disagrees with + /// theirs is this rung's problem rather than the vector's. + pub(super) const H264_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" + ); + + /// The vendored H.265 twin: 250 access units, 320x240 Main 8-bit 4:2:0, ONE slice + /// per picture, one `IDR_N_LP` then 249 TRAIL pictures. + pub(super) const H265_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" + ); + + /// HEVC **Main 10**: 50 access units of 320x240 4:2:0 ten-bit, from libx265. + /// + /// Worth a third leg rather than a variation on the second because ten bits is a + /// different VAAPI PROFILE (`VAProfileHEVCMain10`), a different render-target + /// format (`VA_RT_FORMAT_YUV420_10`) and a different surface fourcc (**P010**, not + /// NV12) — three branches of `Session::build` that no 8-bit leg reaches, on the + /// path every HDR session takes. `finish` refuses a surface whose exported fourcc + /// is not the one the pool was created with, so this leg is also the only thing + /// that would catch a driver quietly handing back NV12 for a ten-bit stream. + pub(super) const MAIN10_H265: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/test-main10.h265"); + + /// Both 8-bit vectors are 250 access units. + const H26X_AU_COUNT: usize = 250; + /// The Main 10 vector is 50. + const MAIN10_AU_COUNT: usize = 50; + + /// How many frames each vector yields THROUGH THIS RUNG: **every picture it + /// displays**, which is what the vectors contain and what the Vulkan and D3D11VA + /// legs have always delivered. + /// + /// Three things have to hold together for these to be the frame counts rather than + /// something smaller, and until 2026-08-07 none of them did: + /// + /// * [`settle`] claims **every** output, not `outputs.last()`. An access unit that + /// bumps several pictures out of the DPB — what an IDR with + /// `no_output_of_prior_pics_flag` clear does, and what ordinary reordering does + /// whenever the buffer drains — used to display the last and retire the rest. + /// * [`NativeVaapiDecoder::deliverable`] carries the surplus to the access units + /// that output nothing, since the pump takes one frame per call. On a reordering + /// stream those units are exactly where the reorder buffer refills, which is why + /// the queue drains: the vendored H.264 vector's three seven-picture drains are + /// each followed by precisely six output-less access units. + /// * [`NativeVaapiDecoder::flush`] drains the DPB at the end, or the tail the + /// planner is still buffering never comes out at all. + /// + /// Derived, not recorded, by [`the_planner_already_says_how_many_frames_these_legs_can_deliver`], + /// which simulates all three over the real vectors on any CPU with no libva: + /// + /// | vector | AUs | pictures the planner outputs | stranded in the DPB | delivered | + /// |---|---|---|---|---| + /// | H.264 | 250 | 243 (25 units output none, 222 one, 3 seven) | 7 | **250** | + /// | H.265 | 250 | 249 (46 none, 159 one, 45 two) | 1 | **250** | + /// | Main 10 | 50 | 48 (5 none, 42 one, 3 two) | 2 | **50** | + /// + /// ⚠ The "flushed" count each leg prints is NOT the stranded column: a flush hands + /// back the deliverable queue's leftovers as well as the DPB's tail. Measured on + /// `.25`, H.264 flushes 7 (nothing left queued), H.265 flushes 2 (one still queued + /// plus its one stranded picture) and Main 10 flushes 2. The totals are what these + /// constants pin, because the split between the two is a property of where the + /// output-less access units happen to fall. + /// + /// ⚠ What this REPLACED, kept as [`H264_LAST_ONLY`] and asserted as a + /// counterfactual rather than described: one frame per access unit and no flush + /// delivered 225 / 204 / 45. That defect could not bite punktfunk's own streams — + /// hosts emit zero-reorder low-delay output with no B pictures, so `outputs` never + /// holds more than one picture and the queue is empty on every access unit — which + /// is exactly why it survived until a conformance vector was pointed at the rung. + const H264_DELIVERED: usize = 250; + const H265_DELIVERED: usize = 250; + const MAIN10_DELIVERED: usize = 50; + + /// What the rung delivered until 2026-08-07: `outputs.last()` per access unit and + /// no end-of-stream flush. + /// + /// Kept as constants because a counterfactual with no expected value is a + /// counterfactual that cannot fail. These are what + /// [`the_planner_already_says_how_many_frames_these_legs_can_deliver`] reproduces + /// when it runs the simulation with a queue bound of zero and no flush — the two + /// halves of the old behaviour — and they are the numbers the three hardware legs + /// asserted when they were written. + const H264_LAST_ONLY: usize = 225; + const H265_LAST_ONLY: usize = 204; + const MAIN10_LAST_ONLY: usize = 45; + + /// Byte offsets of every Annex-B NAL header in `stream`, in order. + /// + /// Emulation prevention guarantees `00 00 01` cannot appear inside a NAL payload, + /// so scanning for it finds start codes and nothing else; the header begins on the + /// byte after. Hand-rolled for the same reason [`split_ivf`] is — `pf-client-core` + /// does not depend on the vendored parser crate — and a VERBATIM port of + /// `video_d3d11_native`'s, so the two platform rungs are driven over the same + /// access units rather than over two splitters free to disagree. Kept honest by + /// the AU counts [`the_annex_b_splitters_still_cut_the_vendored_vectors`] asserts + /// on every ordinary Linux test run, which no plausible splitter bug survives. + fn nal_headers(stream: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut i = 0usize; + while i + 3 <= stream.len() { + if stream[i..i + 3] == [0x00, 0x00, 0x01] { + out.push(i + 3); + i += 3; + } else { + i += 1; + } + } + out + } + + /// Split `stream` into access units, given a per-NAL `(is_slice, starts_a_picture)` + /// rule. A new AU begins at a non-VCL NALU following slices, or at a slice that + /// declares itself the first of a picture when the current AU already has slices — + /// the same rule pf-bitstream applies, spelled once for both codecs. + fn split_aus(stream: &[u8], classify: impl Fn(&[u8], usize) -> (bool, bool)) -> Vec<&[u8]> { + let mut aus = Vec::new(); + let mut au_start = 0usize; + let mut au_has_slice = false; + for header in nal_headers(stream) { + let (is_slice, first_in_picture) = classify(stream, header); + // The start code owning this header: three bytes, plus the optional + // leading zero byte of the four-byte form. + let mut start = header - 3; + if start > 0 && stream[start - 1] == 0x00 { + start -= 1; + } + if au_has_slice && (!is_slice || first_in_picture) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus + } + + /// H.264: one-byte NAL header, `nal_unit_type` in the low 5 bits (1 = non-IDR + /// slice, 5 = IDR slice), and `first_mb_in_slice == 0` is the top bit of the byte + /// after it. Load-bearing rather than decorative on this vector: it codes two + /// slices per picture, so without the flag every picture would be split in two. + pub(super) fn split_h264_aus(stream: &[u8]) -> Vec<&[u8]> { + split_aus(stream, |s, h| { + let is_slice = matches!(s[h] & 0x1f, 1 | 5); + let first = is_slice && s.get(h + 1).is_some_and(|b| b & 0x80 != 0); + (is_slice, first) + }) + } + + /// H.265: TWO-byte NAL header, `nal_unit_type` in bits 1..7 of the first byte and + /// "is a slice" the numeric range `< 32`, so `first_slice_segment_in_pic_flag` is + /// the top bit of the byte at `+2` where H.264 reads `+1`. Getting either wrong + /// silently merges or splits AUs, which surfaces as a frame-count mismatch a long + /// way from its cause. + pub(super) fn split_h265_aus(stream: &[u8]) -> Vec<&[u8]> { + split_aus(stream, |s, h| { + let is_slice = (s[h] >> 1) & 0x3f < 32; + let first = is_slice && s.get(h + 2).is_some_and(|b| b & 0x80 != 0); + (is_slice, first) + }) + } + + /// The splitters cut both vendored vectors into the access units every other rung's + /// legs count, and the Main 10 vector really is ten-bit. + /// + /// NOT `#[ignore]`d, unlike everything below it: the splitters are pure CPU, they + /// are a hand-rolled copy of code that lives in two other crates, and a drift in + /// them would reach the hardware legs as a frame-count mismatch on a box someone + /// had to walk to. Ordinary `cargo test -p pf-client-core --lib` catches it here + /// instead. + /// + /// The ten-bit check is the same guard `video_d3d11_native` carries and for the + /// same reason: a regenerated vector that came out 8-bit would turn + /// [`hevc_main10_decodes_the_ten_bit_vector_on_this_machines_vaapi`] into a second + /// run of the 8-bit path wearing a ten-bit name, and it would pass. + #[test] + fn the_annex_b_splitters_still_cut_the_vendored_vectors() { + assert_eq!( + split_h264_aus(H264_25FPS).len(), + H26X_AU_COUNT, + "H.264 vector access units" + ); + assert_eq!( + split_h265_aus(H265_25FPS).len(), + H26X_AU_COUNT, + "H.265 vector access units" + ); + + let main10 = split_h265_aus(MAIN10_H265); + assert_eq!(main10.len(), MAIN10_AU_COUNT, "Main 10 vector access units"); + + let mut planner = pf_vaadec::H265Planner::new(); + let plan = planner + .plan_au(main10[0]) + .expect("the Main 10 vector's first access unit must plan"); + assert_eq!( + ( + plan.picture.chroma_format_idc, + plan.picture.bit_depth_luma_minus8 + ), + (1, 2), + "the Main 10 vector must be 4:2:0 at ten bits" + ); + } + + /// One access unit's whole effect on the DPB, as the planner reports it — enough + /// to simulate both the DELIVERY and the SURFACE CLAIMS with no device. + /// + /// `stored` is a LIST rather than an `Option` because an AV1 access unit is a + /// temporal unit and may decode several pictures; H.264 and H.265 fill it with the + /// nought or one their planners report. + #[derive(Debug, Default, Clone)] + struct AuEffect { + stored: Vec, + outputs: Vec, + removed: Vec, + } + + /// A whole vector, as this rung's model of it: every access unit's effect, the + /// end-of-stream flush's, and the DPB depth the session would be built for — which + /// is also the queue bound [`max_deliverable`] derives. + struct VectorEffects { + aus: Vec, + flush: AuEffect, + /// `plan.picture.max_dpb_frames`, read from the stream rather than assumed. + max_dpb_frames: usize, + } + + /// [`VectorEffects`] for an H.264 vector. + /// + /// Two small walks rather than one generic one because the two planners share no + /// trait: `AuPlan` and `AuPlanH265` are different types with the same `dpb` fields, + /// which is exactly the shape a macro would obscure for six saved lines. + fn effects_h264(aus: &[&[u8]]) -> VectorEffects { + let mut planner = pf_vaadec::H264Planner::new(); + let mut max_dpb_frames = 0usize; + let walked = aus + .iter() + .map(|au| { + let plan = planner + .plan_au(au) + .expect("the vendored H.264 vector plans"); + max_dpb_frames = max_dpb_frames.max(plan.picture.max_dpb_frames); + AuEffect { + stored: plan.dpb.stored.into_iter().collect(), + outputs: plan.dpb.outputs.clone(), + removed: plan.dpb.removed.clone(), + } + }) + .collect(); + let update = planner.flush(); + VectorEffects { + aus: walked, + flush: AuEffect { + stored: Vec::new(), + outputs: update.outputs, + removed: update.removed, + }, + max_dpb_frames, + } + } + + /// [`effects_h264`] for HEVC. A skipped RASL picture is an access unit with no + /// effect at all, which is what the rung does with it too + /// ([`NativeVaapiDecoder::decode_h265`]). + fn effects_h265(aus: &[&[u8]]) -> VectorEffects { + let mut planner = pf_vaadec::H265Planner::new(); + let mut max_dpb_frames = 0usize; + let walked = aus + .iter() + .map(|au| match planner.plan_au(au) { + Ok(plan) => { + max_dpb_frames = max_dpb_frames.max(plan.picture.max_dpb_frames); + AuEffect { + stored: plan.dpb.stored.into_iter().collect(), + outputs: plan.dpb.outputs.clone(), + removed: plan.dpb.removed.clone(), + } + } + Err(pf_vaadec::PlanErrorH265::RaslSkipped { .. }) => AuEffect::default(), + Err(e) => panic!("the vendored HEVC vector must plan: {e:?}"), + }) + .collect(); + let update = planner.flush(); + VectorEffects { + aus: walked, + flush: AuEffect { + stored: Vec::new(), + outputs: update.outputs, + removed: update.removed, + }, + max_dpb_frames, + } + } + + /// What a whole vector does to this rung. + #[derive(Debug, PartialEq, Eq)] + struct Delivery { + /// Frames the pump receives, flush included. + delivered: usize, + /// Frames decoded correctly and discarded because the queue hit its bound. + dropped: usize, + /// The most surfaces claimed at once, at the moment [`Session::acquire_target`] + /// looks for a free one — a live slot, a pending output, a queued frame or the + /// frame the consumer was just handed. + peak_claim: usize, + } + + /// Drive one vector through this rung's DELIVERY MODEL — the ledger, the deliverable + /// queue, the one-frame-per-access-unit hand-off and the end-of-stream flush — with + /// no libva, no device and no surfaces. + /// + /// A faithful re-walk of [`NativeVaapiDecoder::decode`] in the order it does things, + /// because the order is where the defects were: the decode target is taken BEFORE + /// this unit's removals settle, outputs are claimed BEFORE `removed` retires them, + /// and the trim runs AFTER this unit's own frame has been shipped. + /// + /// `flush` is `None` for the counterfactual that reproduces the old behaviour. + fn simulate(effects: &[AuEffect], flush: Option<&AuEffect>, cap: usize) -> Delivery { + let mut live: std::collections::BTreeSet = Default::default(); + let mut pending: Vec = Vec::new(); + let mut queue: std::collections::VecDeque = Default::default(); + let (mut delivered, mut dropped, mut peak_claim) = (0usize, 0usize, 0usize); + let mut consumer = 0usize; + + /// [`settle`] itself, over ids: outputs claimed into the queue in display + /// order, THEN whatever left the DPB retired whether it was output or not. + fn settle_ids( + effect: &AuEffect, + live: &mut std::collections::BTreeSet, + pending: &mut Vec, + queue: &mut std::collections::VecDeque, + ) { + for id in &effect.outputs { + if let Some(index) = pending.iter().position(|p| p == id) { + queue.push_back(pending.remove(index)); + } + } + for id in &effect.removed { + live.remove(id); + pending.retain(|p| p != id); + } + } + + for effect in effects { + // `acquire_target` runs first and needs one free surface on top of + // everything already claimed — plus the frame the consumer was handed last + // access unit, which it has not necessarily let go of. + let claimed = + live.len() + pending.iter().filter(|p| !live.contains(p)).count() + queue.len(); + peak_claim = peak_claim.max(claimed + consumer + 1); + + // The conversion applies this unit's removals to the slot ledger, then + // `bind_setup` binds the picture it decoded. + for id in &effect.removed { + live.remove(id); + } + for id in &effect.stored { + live.insert(*id); + pending.push(*id); + } + settle_ids(effect, &mut live, &mut pending, &mut queue); + + // `take_deliverable`: this unit's own frame off the front, then the bound. + consumer = usize::from(queue.pop_front().is_some()); + delivered += consumer; + while queue.len() > cap { + queue.pop_front(); + dropped += 1; + } + } + + if let Some(effect) = flush { + // `flush` hands back the queue AND everything the planner was still + // buffering, all at once — it is not bounded by the pump's one-per-call + // contract, because there is no next call. + settle_ids(effect, &mut live, &mut pending, &mut queue); + delivered += queue.len(); + queue.clear(); + assert!( + pending.is_empty(), + "a flush leaves nothing owing an output: {pending:?}" + ); + } + Delivery { + delivered, + dropped, + peak_claim, + } + } + + /// The three delivered-frame counts the hardware legs assert are what the PLANNER + /// implies, not what a hardware run happened to print — **and** the old behaviour + /// beside them, so the fix is a difference rather than an assertion. + /// + /// This is the difference between a number that explains itself and a number + /// somebody wrote down: it runs on any Linux box, with no GPU and no libva, and it + /// fails the moment a vector is regenerated or the planner's bumping changes — + /// which would otherwise show up as three mysterious hardware failures on a machine + /// somebody had to walk to. + #[test] + fn the_planner_already_says_how_many_frames_these_legs_can_deliver() { + let vectors = [ + ( + "H.264", + effects_h264(&split_h264_aus(H264_25FPS)), + H264_DELIVERED, + H264_LAST_ONLY, + ), + ( + "H.265", + effects_h265(&split_h265_aus(H265_25FPS)), + H265_DELIVERED, + H265_LAST_ONLY, + ), + ( + "Main 10", + effects_h265(&split_h265_aus(MAIN10_H265)), + MAIN10_DELIVERED, + MAIN10_LAST_ONLY, + ), + ]; + + for (label, vector, expected, last_only) in &vectors { + // The bound the rung would actually run with, read off the stream rather + // than chosen here — `max_deliverable` is `max_dpb_frames`. + let cap = vector.max_dpb_frames; + let run = simulate(&vector.aus, Some(&vector.flush), cap); + assert_eq!( + run.delivered, *expected, + "{label}: every displayed picture must reach the pump (cap {cap}, \ + {run:?})" + ); + assert_eq!( + run.dropped, 0, + "{label}: and none of them may be dropped for want of queue depth" + ); + + // ⚠ The counterfactual: the two halves of the old behaviour, together. A + // queue bound of 0 is "one picture per access unit, the rest retired + // unshown"; no flush is "the tail never comes out". That is what the rung + // did until 2026-08-07, and it is what these three legs asserted. + let old = simulate(&vector.aus, None, 0); + assert_eq!( + old.delivered, *last_only, + "{label}: the pre-fix model must still reproduce the number the \ + hardware legs measured, or this is not the defect that was fixed" + ); + assert!( + old.delivered < run.delivered, + "{label}: and it must be SHORT — a counterfactual that delivers \ + everything is not a counterfactual" + ); + } + } + + /// The deliverable queue never asks the pool for a surface it does not have. + /// + /// This is [`max_deliverable`]'s surface-lifetime argument run over the real + /// vectors rather than asserted. A queued frame INHERITS the claim its picture had + /// as a DPB reference — [`settle`] takes it out of `pending` in the same breath the + /// bump released its slot — so the queue's marginal cost is **at most one surface**, + /// measured at exactly zero on the H.264 vector, whose three seven-picture drains + /// are the deepest bursts any of these vectors produce. + /// + /// The counterfactual is the bound itself: an UNBOUNDED queue on a stream that + /// bumps two pictures on every access unit grows by one per unit until the pool + /// runs out, which is the failure `max_deliverable` exists to prevent and the one + /// no log would explain. + #[test] + fn the_queue_never_needs_a_surface_the_pool_does_not_have() { + // The peaks are pinned, not merely bounded: a change that quietly claimed two + // more surfaces would still fit the pool and would still be a change worth + // seeing. `without` is the same walk with no carry-over at all — the rung as it + // stood before the queue existed. + for (label, vector, peak, without) in [ + ("H.264", effects_h264(&split_h264_aus(H264_25FPS)), 9, 9), + ("H.265", effects_h265(&split_h265_aus(H265_25FPS)), 8, 7), + ("Main 10", effects_h265(&split_h265_aus(MAIN10_H265)), 8, 7), + ] { + let dpb = vector.max_dpb_frames; + let pool = pf_vaadec::surface_count(dpb); + let run = simulate(&vector.aus, Some(&vector.flush), dpb); + let queueless = simulate(&vector.aus, Some(&vector.flush), 0); + + // The claim counted here is the DECODER's: live slots, pictures owing an + // output, queued frames, the frame just handed over and the target about to + // be taken. Everything the pool holds beyond it is the presenter's. + assert_eq!( + (run.peak_claim, queueless.peak_claim), + (peak, without), + "{label}: peak surfaces claimed at once, with the queue and without it \ + (dpb {dpb}, pool {pool})" + ); + assert!( + run.peak_claim <= queueless.peak_claim + 1, + "{label}: the queue must INHERIT the DPB's claim, not add to it — \ + {} against {}", + run.peak_claim, + queueless.peak_claim + ); + assert!( + pool - run.peak_claim >= pf_vaadec::config::PRESENTER_HEADROOM - 2, + "{label}: {} of a {pool}-surface pool claimed, leaving {} of the \ + {}-surface presenter headroom — a session that cannot find a free \ + surface refuses the access unit and demotes the rung", + run.peak_claim, + pool - run.peak_claim, + pf_vaadec::config::PRESENTER_HEADROOM, + ); + } + + // ⚠ On H.264 and H.265 the queue is SELF-limiting and the bound never has to + // engage: an access unit decodes at most one picture, so it can only ever + // output what earlier units decoded, and the queue sheds one per unit. That is + // why the three vectors above drop nothing at any depth — and it is exactly + // why a bound is still needed, because the property is a fact about those + // codecs rather than about this rung. An AV1 temporal unit may decode SEVERAL + // pictures, and a non-conformant one that showed two per unit would grow the + // queue by one per unit until the pool ran out: every access unit after that + // refuses with "surface pool exhausted", three in a second demote the rung, + // and nothing in the log would name a queue that could never drain. + let relentless: Vec = (0..64u64) + .map(|i| AuEffect { + stored: vec![i * 2, i * 2 + 1], + outputs: vec![i * 2, i * 2 + 1], + removed: vec![i * 2, i * 2 + 1], + }) + .collect(); + let bounded = simulate(&relentless, None, 4); + assert!( + bounded.dropped > 0, + "the bound must engage on a temporal unit shape that never lets the queue \ + drain" + ); + assert!( + bounded.peak_claim <= 4 + 4, + "and hold the claim flat: peak {}", + bounded.peak_claim + ); + let unbounded = simulate(&relentless, None, usize::MAX); + assert_eq!( + unbounded.dropped, 0, + "unbounded drops nothing — it just grows" + ); + assert!( + unbounded.peak_claim > bounded.peak_claim * 2, + "without the bound the same stream grows a queue nothing can drain — peak \ + {} against {}", + unbounded.peak_claim, + bounded.peak_claim + ); + } + + /// The first frame a leg got back — enough to say the rung exported a real surface + /// of the right shape and pixel format, which is the most it can honestly claim. + #[derive(Clone, Copy)] + struct FirstFrame { + width: u32, + height: u32, + fourcc: u32, + modifier: u64, + keyframe: bool, + } + + /// Drive one Annex-B vector's access units through a freshly built rung and report + /// how many frames came back and what the first one was. + /// + /// Shared by all three H.26x legs so that "the H.265 leg proves the same thing the + /// H.264 leg does" is a fact about one function rather than a claim about three + /// hand-copied ones — the same reasoning `pf-vkdecode`'s `common` module records + /// for binding its three decoders to one driver. + /// + /// # What these legs prove, and what they do not + /// + /// A DECODE measurement, not frame-hash parity: every access unit is accepted, the + /// expected number of frames comes back, and each one is a real exported surface of + /// the right shape and fourcc. Nothing here looks at a PIXEL. + /// + /// That used to be all this rung could claim, because it hands out a **DRM-PRIME + /// dmabuf** whose memory the driver tiles and there was no CPU-readable image to + /// hash. The `parity` module below adds one, test-only, and its legs are what check + /// the pixels against libavcodec's goldens — the same goldens the Vulkan and + /// D3D11VA rungs are held to. These legs stay because they are the cheaper question + /// and they fail FIRST: a rung that stopped decoding at all should say so without + /// waiting for 250 hashes. + /// + /// Fails loudly rather than skipping when the device has no entry point for the + /// profile: these legs are `#[ignore]`d, so they only run when someone deliberately + /// points them at a box that is supposed to have one, and a silent pass there is + /// the invisible-failure mode this whole program exists to end. + fn run_annex_b( + codec: pf_vaadec::Codec, + stream: StreamFormat, + aus: &[&[u8]], + label: &str, + ) -> (usize, FirstFrame) { + let mut decoder = NativeVaapiDecoder::new(codec, stream).unwrap_or_else(|e| { + panic!( + "{label}: this box is supposed to have a VAAPI {label} decode entry point: {e:#}" + ) + }); + eprintln!("VAAPI {label} rung constructed: {}", decoder.name()); + + let mut delivered = 0usize; + let mut first: Option = None; + let mut record = |frame: &DmabufFrame, where_: &str| { + assert!( + !frame.planes.is_empty(), + "{label} {where_}: a delivered frame exported no dmabuf planes" + ); + if first.is_none() { + first = Some(FirstFrame { + width: frame.width, + height: frame.height, + fourcc: frame.fourcc, + modifier: frame.modifier, + keyframe: frame.keyframe, + }); + } + }; + for (index, au) in aus.iter().enumerate() { + match decoder.decode(au) { + Ok(Some(frame)) => { + record(&frame, &format!("AU {index}")); + delivered += 1; + } + Ok(None) => {} + Err(e) => panic!("{label} AU {index}: VAAPI decode failed: {e:#}"), + } + } + // The tail: pictures the planner was still buffering for reorder when the + // vector ran out. Seven of 250 on H.264, one on H.265, two of 50 on Main 10 — + // decoded, never asked for, and lost outright until this rung had a flush. + let flushed = decoder.flush(); + for (index, frame) in flushed.iter().enumerate() { + record(frame, &format!("flushed frame {index}")); + } + let tail = flushed.len(); + delivered += tail; + + let first = first.unwrap_or_else(|| panic!("{label}: not one frame came back")); + let FirstFrame { + width, + height, + fourcc, + modifier, + keyframe, + } = first; + eprintln!( + "VAAPI {label}: {delivered} frames from {} access units ({tail} of them \ + flushed at end of stream), first {width}x{height} fourcc={:?} \ + modifier={modifier:#x} keyframe={keyframe}", + aus.len(), + std::str::from_utf8(&fourcc.to_le_bytes()).unwrap_or("?"), + ); + (delivered, first) + } + + /// Does this machine's VAAPI actually DECODE H.264 — the question the evidence + /// table has answered "no hardware has ever tried" since M6. + /// + /// See [`run_annex_b`] for what this proves and, more importantly, what it does + /// not: it is a decode measurement. The pixels are + /// `parity::h264_every_frame_hashes_bit_identical_to_libavcodec`'s business. + #[test] + #[ignore = "needs a machine with a libva runtime and an H.264 VLD entry point"] + fn h264_decodes_the_vendored_vector_on_this_machines_vaapi() { + let aus = split_h264_aus(H264_25FPS); + assert_eq!(aus.len(), H26X_AU_COUNT, "the H.264 vector is 250 AUs"); + + let (delivered, first) = run_annex_b( + pf_vaadec::Codec::H264, + StreamFormat::SDR_420_8, + &aus, + "H.264", + ); + assert_eq!((first.width, first.height), (320, 240), "320x240"); + assert_eq!( + first.fourcc, + pf_vaadec::VA_FOURCC_NV12, + "an 8-bit pool exports NV12" + ); + assert_eq!( + delivered, H264_DELIVERED, + "every picture the vector displays must reach the pump — see \ + H264_DELIVERED, and the_planner_already_says_how_many_frames_these_legs_\ + can_deliver for the same number derived without a device" + ); + + // The first frame delivered IS the IDR, bumped out several access units after + // it decoded — so this is the label travelling with the PICTURE rather than + // with whatever access unit displaced it. It is `DecodedImage::is_keyframe`, + // the pump's post-loss re-anchor signal: mislabelled (as it was until + // 2026-08-07) the pump re-anchors on the wrong frame and keeps asking for a + // keyframe it has already been sent. + assert!( + first.keyframe, + "the vector opens on an IDR, so the first delivered frame must be flagged \ + as a keyframe" + ); + } + + /// The same question for H.265, whose leg has never decoded a frame either. + /// + /// Not a redundant copy of the H.264 leg: HEVC reaches an entirely different + /// conversion in `pf-vaadec` (its own picture parameters, its own reference-picture + /// set, its own slice header) and a different arm of [`NativeVaapiDecoder::decode`] + /// — including the `RaslSkipped` Ok-skip no other codec has. + #[test] + #[ignore = "needs a machine with a libva runtime and an HEVC Main VLD entry point"] + fn h265_decodes_the_vendored_vector_on_this_machines_vaapi() { + let aus = split_h265_aus(H265_25FPS); + assert_eq!(aus.len(), H26X_AU_COUNT, "the H.265 vector is 250 AUs"); + + let (delivered, first) = run_annex_b( + pf_vaadec::Codec::H265, + StreamFormat::SDR_420_8, + &aus, + "H.265", + ); + assert_eq!((first.width, first.height), (320, 240), "320x240"); + assert_eq!( + first.fourcc, + pf_vaadec::VA_FOURCC_NV12, + "an 8-bit pool exports NV12" + ); + assert_eq!( + delivered, H265_DELIVERED, + "every picture the vector displays must reach the pump" + ); + assert!( + first.keyframe, + "the vector opens on an IDR_N_LP — the same picture-not-access-unit label \ + the H.264 leg documents" + ); + } + + /// And the ten-bit leg, which is the one every HDR session lands on. + /// + /// The fourcc assertion is the point of running it at all: `Session::build` picks + /// P010 from the SPS's bit depth, and a pool that came out NV12 would be a ten-bit + /// stream decoded into an 8-bit surface. + #[test] + #[ignore = "needs a machine with a libva runtime and an HEVC Main 10 VLD entry point"] + fn hevc_main10_decodes_the_ten_bit_vector_on_this_machines_vaapi() { + let aus = split_h265_aus(MAIN10_H265); + assert_eq!(aus.len(), MAIN10_AU_COUNT, "the Main 10 vector is 50 AUs"); + + let (delivered, first) = run_annex_b( + pf_vaadec::Codec::H265, + StreamFormat { + bit_depth: 10, + ..StreamFormat::SDR_420_8 + }, + &aus, + "HEVC Main 10", + ); + assert_eq!((first.width, first.height), (320, 240), "320x240"); + assert_eq!( + first.fourcc, + pf_vaadec::VA_FOURCC_P010, + "a ten-bit stream must build a P010 pool, not an 8-bit one" + ); + assert_eq!( + delivered, MAIN10_DELIVERED, + "every picture the vector displays must reach the pump" + ); + assert!( + first.keyframe, + "the same picture-not-access-unit label the H.264 leg documents" + ); + } +} + +#[cfg(test)] +mod parity { + //! Frame-hash parity for this rung — the evidence M6 and M7 shipped without, and + //! the last rung of the ladder that had none. + //! + //! `#[ignore]`d: every leg needs a real VAAPI device. Run them on a box with + //! + //! ```text + //! cargo test -p pf-client-core --lib video_vaapi_native -- --include-ignored --nocapture + //! ``` + //! + //! and pin a GPU on a multi-GPU box with `PUNKTFUNK_VAAPI_DEVICE=/dev/dri/renderD…` + //! (the same pin the rung itself honours). + //! + //! # What it proves, and against what + //! + //! Exactly what `pf-vkdecode`'s `gpu_parity` proves for the Vulkan rung and + //! `video_d3d11_native`'s `parity` for the D3D11VA one, against the same reference + //! and — deliberately — the SAME GOLDEN FILES, read across the crate boundary + //! rather than copied: H.264, H.265 and AV1 decoding are exactly specified, so a + //! conformant decoder must reproduce libavcodec's SOFTWARE output bit for bit. One + //! golden set for three rungs is what makes their verdicts directly comparable; + //! three copies would be three measurements. + //! + //! Until this module existed the VAAPI rung's four legs could only claim that every + //! access unit was ACCEPTED and that a surface of the right shape came back. That + //! is a much weaker claim than it reads as, and this program has now been shown + //! exactly how much weaker: the D3D11VA AV1 rung streamed 4K60 for five clean + //! minutes while producing wrong pixels for 186 of 250 frames on one GPU and 245 of + //! 250 on another. Nothing but a golden caught it. + //! + //! # Measured + //! + //! **All seven legs, 2026-08-08, on `.25`** — Radeon 780M (RDNA3), radeonsi, Mesa + //! 26.0.3, VA-API 1.23, `/dev/dri/renderD128`: + //! + //! | leg | frames | flush tail | verdict | + //! |---|---|---|---| + //! | H.264, vendored vector | 250 | 7 | bit-identical | + //! | H.264, our host's low-delay 640x480 | 120 | 3 | bit-identical | + //! | H.265, vendored vector | 250 | 2 | bit-identical | + //! | H.265, our host's low-delay 640x480 | 120 | 0 | bit-identical | + //! | HEVC Main 10, P010 | 50 | 2 | bit-identical | + //! | AV1, vendored vector | 250 delivered of 274 decoded | 0 | bit-identical, and display frame 0 byte-identical to libavcodec's own PIXELS | + //! | AV1, our host's 4K two-tile | 60 | 0 | bit-identical | + //! + //! `vaDeriveImage` answers on radeonsi and is the route every leg took. `vaGetImage` + //! also answers; the two agreed byte for byte on every leg's first frame, and + //! `PF_VAAPI_READBACK=getimage` reproduces the H.264 leg's 250/250 through the + //! copying route alone — so the fallback is exercised rather than merely written. + //! + //! ⚠ ONE vendor. This is AMD/radeonsi only; Intel's iHD driver has a different + //! surface layout and a different `vaDeriveImage` answer, and no Intel box has run + //! these legs. The D3D11VA AV1 defect was invisible on NVIDIA for 64 frames and + //! structural on Intel from frame 4 — one driver passing is evidence about that + //! driver. + //! + //! # ⚠ The readback is TEST-ONLY, and that is structural rather than a promise + //! + //! The production path exports a DRM-PRIME dmabuf and the presenter samples it. + //! Nothing on it maps a surface, and nothing may: a per-frame CPU readback on the + //! live path would cost exactly the copy zero-copy exists to avoid. Four things + //! keep this module off it, and the first is the one that matters: + //! + //! 1. **The entry points are resolved HERE, in `#[cfg(test)]` code.** [`ImageApi`] + //! dlopens `libva.so.2` itself and stores the image function pointers in a type + //! that does not exist outside `cargo test`. In a shipped build there is no + //! `vaMapBuffer` pointer to call, so no production path can reach one however + //! wrong it becomes. + //! 2. **The production [`Libva`] gains no field.** Its list of entry points is + //! unchanged by this module, which is the one-screen check a reviewer can do. + //! 3. [`the_readback_entry_points_are_resolved_only_inside_this_module`] asserts + //! (1) and (2) mechanically, by scanning this file's own source: every `dlsym` + //! of an image entry point must sit after this module's header. It is a CPU + //! test, so ordinary `cargo test` enforces it on every platform. + //! 4. `sha2` is a DEV dependency, so nothing shipped links the hashing either. + //! + //! # Two routes, because derive is not guaranteed + //! + //! libva offers two ways to read a surface, and a driver need only implement one: + //! + //! * **`vaDeriveImage`** maps the surface's own memory. Cheap, and refused outright + //! by drivers whose decode surfaces are tiled or otherwise not linearly + //! addressable. + //! * **`vaCreateImage` + `vaGetImage`** asks the driver to copy — and detile — the + //! region into an image of a format it declares it can produce. + //! + //! [`Readback`] tries derive first, falls back to create+get, and **fails loudly + //! naming what the driver gave it** if neither yields the pool's own fourcc. A + //! parity test that quietly passed because it could not read anything is the + //! failure mode this program has been bitten by three times; there is no skip path + //! here. `PF_VAAPI_READBACK=derive|getimage` forces one route, and the first frame + //! of every leg is read through BOTH when both work and the two must agree — which + //! is the only check that can catch a derive that "succeeds" onto tiled bytes. + //! + //! # It hashes what the rung DELIVERS, in the order it delivers it + //! + //! The goldens are one hash per DISPLAY frame. Since the delivery fix this rung + //! hands back every displayed picture in display order — `settle` claims every + //! output rather than only the last, and [`NativeVaapiDecoder::flush`] drains the + //! tail the DPB is still holding — so delivery order IS golden order and the + //! comparison is a straight zip. Three things follow, and all three are why this + //! shape was chosen over hashing decoded pictures by `PicId`: + //! + //! * a frame's surface comes from its OWN release token, so the harness never has + //! to infer which surface holds which picture — an inference that was subtly + //! wrong in an earlier draft of this file, because a surface freed at the top of + //! an access unit can be taken as that same unit's decode target and so never + //! looks newly held; + //! * the DELIVERY path is under test too. A rung that decoded perfectly and + //! presented in the wrong order, or dropped a picture, fails here — and dropping + //! pictures is precisely what this rung did until 2026-08-08; + //! * the frame carries its own display region and keyframe flag + //! ([`PictureFacts`]), so the harness reads geometry from the same place the + //! presenter does rather than from a second guess. + //! + //! ⚠ What this shape does NOT cover: AV1's **hidden frames**. 24 of the vendored + //! vector's 274 decoded pictures are never displayed, so they are never delivered + //! and never hashed here. They are not unverified — every shown frame after one + //! predicts from it, so a hidden picture decoded wrong shows up as a wrong hash on + //! the frames that reference it — but a defect confined to a hidden frame's own + //! pixels would be seen one frame late rather than at once. + //! + //! # The crop, and the ten-bit trap + //! + //! Surfaces are allocated at the CODED size and are taller than the picture, so the + //! chroma plane starts at the driver's own `offsets[1]` and never at + //! `pitch * display_height` — the 1088-row smear this project has already paid for. + //! That walk is [`pf_vaadec::pack_two_plane`], and it is unit-tested with no device + //! at all. Main 10's goldens are **P010**, two bytes per sample with the ten bits in + //! the HIGH end of each little-endian word; a driver handing back LSB-aligned + //! samples produces a buffer of exactly the right length and the wrong content, + //! which [`Divergence::low_bits_set`] is here to name. + + use sha2::Digest; + + use super::tests::split_h264_aus; + use super::tests::split_h265_aus; + use super::tests::split_ivf; + use super::tests::AV1_25FPS; + use super::tests::H264_25FPS; + use super::tests::H265_25FPS; + use super::tests::MAIN10_H265; + use super::*; + + // ----------------------------------------------------------------------- + // The vectors and their goldens — the same files the other two rungs use + // ----------------------------------------------------------------------- + + /// libavcodec's per-display-frame NV12 hashes for the vendored H.264 vector. + /// Read across the crate boundary rather than copied — see the module docs. + const GOLDENS_H264: &str = include_str!("../../pf-vkdecode/tests/data/test-25fps.nv12.sha256"); + const GOLDENS_H265: &str = + include_str!("../../pf-vkdecode/tests/data/test-25fps-h265.nv12.sha256"); + const GOLDENS_MAIN10: &str = + include_str!("../../pf-vkdecode/tests/data/test-main10.p010.sha256"); + const GOLDENS_AV1: &str = + include_str!("../../pf-vkdecode/tests/data/test-25fps-av1.nv12.sha256"); + + /// **Our own host's low-delay H.264** and its goldens — the stream shape a + /// conformance vector cannot be, and the one that caught the D3D11VA rung's + /// release-ordering defect. 120 pictures of 640x480 IPPP with + /// `max_num_reorder_frames = 0` and a DPB exactly as deep as its three references. + /// + /// This rung is argued EXEMPT from that defect for a reason that is a property of + /// the interface rather than of any stream (this file's module docs): a slot is not + /// a surface here, and [`Session::acquire_target`] takes the target and the + /// reference table from one snapshot. That argument is good; it had never been + /// checked in PIXELS on the stream it is about, and "we reasoned it cannot happen" + /// is what the other two rungs also believed. + const LOWDELAY_H264: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264"); + const GOLDENS_LOWDELAY_H264: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256"); + + /// The HEVC twin of [`LOWDELAY_H264`]: 120 pictures of 640x480 IPPP, + /// `sps_max_num_reorder_pics = 0`, 115 of the 120 access units retiring a picture. + const LOWDELAY_H265: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h265"); + const GOLDENS_LOWDELAY_H265: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256"); + + /// **Our own host's AV1**, and the only stream this rung decodes with more than ONE + /// TILE: at 4K the split encode emits `tile_cols = 1, tile_rows = 2` with both tiles + /// in a single Tile Group OBU. 1440p and below measured single-tile, so 4K is the + /// only shape that has the property. + /// + /// ⚠ A file fixture is not the wire path, and on AV1 that distinction has already + /// cost a release: "250/250 delivered frames bit-identical" was true for the whole + /// period the host was shipping only the first tile of every 4K frame, because the + /// truncation lived in packetisation. This leg gives the multi-tile shape pixel + /// coverage on the DECODE rung and proves nothing about fragmentation or loss. + const LOWDELAY_AV1: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1"); + const GOLDENS_LOWDELAY_AV1: &str = + include_str!("../../pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256"); + + /// libavcodec's decode of the AV1 vector's FIRST display frame, as raw NV12 — + /// 115200 bytes, and the only golden in this program that is pixels rather than a + /// hash. + /// + /// It buys the one thing a hash cannot: when display frame 0 diverges, this says + /// WHERE. Frame 0 of that vector is a key frame with no references at all, so a + /// divergence there is readback geometry (pitch, crop, plane offset) or the tile + /// records — never reference handling — and [`localise`] separates those by naming + /// the plane, the bounding box and the magnitude. + const AV1_FRAME0_NV12: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/test-25fps-av1.frame0.nv12"); + + /// The vendored vectors' access-unit / temporal-unit counts. + const H26X_AU_COUNT: usize = 250; + const MAIN10_AU_COUNT: usize = 50; + const AV1_UNIT_COUNT: usize = 250; + /// 250 temporal units carrying **274 frames**, of which 250 are shown. + const AV1_DECODED_COUNT: usize = 274; + const AV1_SHOWN_COUNT: usize = 250; + const DISPLAY_AV1: (u32, u32) = (320, 240); + + /// Our host's streams. Three separate constants per AV1 stream, never derived from + /// one another: the vendored vector is 250 / 274 / 250 and this one is 60 / 60 / 60, + /// and a harness that computed "hidden = 0" from either would stop checking the + /// other. + const LOWDELAY_H264_AU_COUNT: usize = 120; + const LOWDELAY_H265_AU_COUNT: usize = 120; + const LOWDELAY_AV1_UNIT_COUNT: usize = 60; + const LOWDELAY_AV1_DECODED_COUNT: usize = 60; + const LOWDELAY_AV1_SHOWN_COUNT: usize = 60; + const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160); + + /// The golden file's hash lines (comments and blanks skipped). + fn golden_hashes(file: &'static str) -> Vec<&'static str> { + file.lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect() + } + + fn sha256_hex(data: &[u8]) -> String { + use std::fmt::Write as _; + sha2::Sha256::digest(data) + .iter() + .fold(String::with_capacity(64), |mut out, byte| { + let _ = write!(out, "{byte:02x}"); + out + }) + } + + // ----------------------------------------------------------------------- + // The readback — libva's image entry points, resolved ONLY here + // ----------------------------------------------------------------------- + + /// The image half of libva, dlopen'd by the harness itself. + /// + /// Deliberately NOT fields on the production [`Libva`]: keeping them in a + /// `#[cfg(test)]` type is what makes "the readback cannot reach the production + /// path" a fact about what is COMPILED rather than a claim about what is called + /// (module docs). `dlopen` is reference-counted, so resolving these out of a second + /// handle on `libva.so.2` reaches the same mapped library and the same + /// per-`VADisplay` driver state as the rung's own handle — the display pointer they + /// are handed is the rung's. + struct ImageApi { + _va: libloading::Library, + derive_image: + unsafe extern "C" fn(VaDisplay, VaSurfaceId, *mut pf_vaadec::VaImage) -> VaStatus, + create_image: unsafe extern "C" fn( + VaDisplay, + *mut pf_vaadec::VaImageFormat, + c_int, + c_int, + *mut pf_vaadec::VaImage, + ) -> VaStatus, + get_image: unsafe extern "C" fn( + VaDisplay, + VaSurfaceId, + c_int, + c_int, + c_uint, + c_uint, + c_uint, + ) -> VaStatus, + destroy_image: unsafe extern "C" fn(VaDisplay, c_uint) -> VaStatus, + map_buffer: unsafe extern "C" fn(VaDisplay, VaBufferId, *mut *mut c_void) -> VaStatus, + unmap_buffer: unsafe extern "C" fn(VaDisplay, VaBufferId) -> VaStatus, + max_image_formats: unsafe extern "C" fn(VaDisplay) -> c_int, + query_image_formats: + unsafe extern "C" fn(VaDisplay, *mut pf_vaadec::VaImageFormat, *mut c_int) -> VaStatus, + } + + impl ImageApi { + fn load() -> Result { + // SAFETY: the same contract `Libva::load` documents — `Library::new` runs + // the trusted system libva's initialisers (already loaded by the rung, so + // this is a refcount bump), and each `lib.get` resolves a documented libva + // symbol AT the field's own type, transcribed from `va.h`. The `Library` + // handle is stored beside the pointers, so every one outlives its uses. + unsafe { + let va = libloading::Library::new("libva.so.2") + .map_err(|e| anyhow!("libva.so.2 (no VAAPI runtime on this system): {e}"))?; + macro_rules! get { + ($lib:expr, $name:literal) => { + *$lib + .get(concat!($name, "\0").as_bytes()) + .map_err(|e| anyhow!(concat!("dlsym ", $name, ": {}"), e))? + }; + } + let derive_image = get!(va, "vaDeriveImage"); + let create_image = get!(va, "vaCreateImage"); + let get_image = get!(va, "vaGetImage"); + let destroy_image = get!(va, "vaDestroyImage"); + let map_buffer = get!(va, "vaMapBuffer"); + let unmap_buffer = get!(va, "vaUnmapBuffer"); + let max_image_formats = get!(va, "vaMaxNumImageFormats"); + let query_image_formats = get!(va, "vaQueryImageFormats"); + Ok(ImageApi { + derive_image, + create_image, + get_image, + destroy_image, + map_buffer, + unmap_buffer, + max_image_formats, + query_image_formats, + _va: va, + }) + } + } + } + + /// Which libva call read the surface. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum Route { + Derive, + GetImage, + } + + /// A `vaCreateImage`d image, reused for every frame of a leg. + struct Staging { + image: pf_vaadec::VaImage, + size: (u32, u32), + fourcc: u32, + } + + /// GPU→CPU readback of one decoded surface, cropped to the picture and packed + /// tightly as NV12/P010 — byte for byte the layout the goldens hash. + struct Readback { + api: ImageApi, + /// Every `VAImageFormat` this driver offers, from `vaQueryImageFormats`. The + /// descriptor `vaCreateImage` is handed is the driver's OWN rather than one + /// this file guessed a `bits_per_pixel` for. + formats: Vec, + staging: Option, + /// `PF_VAAPI_READBACK`, if it pinned a route. + forced: Option, + /// The route that worked, once one has. Latched so a driver that refuses derive + /// pays for that refusal once rather than once per frame. + route: Option, + /// The image size `vaGetImage` accepted — the picture, or the whole surface on + /// a driver that refuses a sub-region. + get_size: Option<(u32, u32)>, + derived: u64, + fetched: u64, + } + + impl Readback { + fn new(d: &Display) -> Readback { + let api = ImageApi::load().expect("libva's image entry points must resolve"); + // SAFETY: a live display; the vector is allocated to the size libva itself + // reports and `count` is a local written through by the call. + let formats = unsafe { + let max = (api.max_image_formats)(d.display); + if max <= 0 { + Vec::new() + } else { + let mut formats = + vec![pf_vaadec::VaImageFormat::default(); max.unsigned_abs() as usize]; + let mut count: c_int = 0; + let status = + (api.query_image_formats)(d.display, formats.as_mut_ptr(), &mut count); + if status == VA_STATUS_SUCCESS { + formats.truncate(count.clamp(0, max) as usize); + formats + } else { + Vec::new() + } + } + }; + let forced = match std::env::var("PF_VAAPI_READBACK").ok().as_deref() { + Some("derive") => Some(Route::Derive), + Some("getimage") => Some(Route::GetImage), + Some(other) => panic!("PF_VAAPI_READBACK={other} — expected derive or getimage"), + None => None, + }; + Readback { + api, + formats, + staging: None, + forced, + route: None, + get_size: None, + derived: 0, + fetched: 0, + } + } + + /// The fourccs this driver says it can produce, for a refusal that names them. + fn offered(&self) -> String { + self.formats + .iter() + .map(|f| { + let b = f.fourcc.to_le_bytes(); + std::str::from_utf8(&b) + .map(str::to_string) + .unwrap_or_else(|_| format!("{:#010x}", f.fourcc)) + }) + .collect::>() + .join(" ") + } + + /// Map an image, pack the picture out of it, unmap. The only place a raw + /// pointer becomes a slice. + fn read_mapped( + &self, + d: &Display, + image: &pf_vaadec::VaImage, + display: (u32, u32), + fourcc: u32, + ) -> std::result::Result, String> { + let mut base: *mut c_void = std::ptr::null_mut(); + // SAFETY: a live display and an image id this call site owns; `base` is a + // local written through. + let status = unsafe { (self.api.map_buffer)(d.display, image.buf, &mut base) }; + if status != VA_STATUS_SUCCESS { + return Err(format!("{:#}", d.va.err("vaMapBuffer", status))); + } + if base.is_null() { + // SAFETY: pairing the successful map above. + unsafe { (self.api.unmap_buffer)(d.display, image.buf) }; + return Err("vaMapBuffer succeeded and returned a null pointer".to_string()); + } + // SAFETY: `vaMapBuffer` returned a pointer to `data_size` readable bytes — + // that is what the field means — and the mapping stays valid until the + // `vaUnmapBuffer` below, which is after the last read. `pack_two_plane` + // bounds-checks every row it takes against this length, so a descriptor + // that disagrees with its own buffer is a refusal rather than a read past + // the end. + let mapped = + unsafe { std::slice::from_raw_parts(base.cast::(), image.data_size as usize) }; + let packed = pf_vaadec::pack_two_plane(image, mapped, display, fourcc).map_err(|e| { + format!( + "{e} — the driver's image is {}x{}, {} plane(s), pitches {:?}, \ + offsets {:?}, data_size {}", + image.width, + image.height, + image.num_planes, + image.pitches, + image.offsets, + image.data_size + ) + }); + // SAFETY: pairing the successful map above; nothing reads `mapped` after. + unsafe { (self.api.unmap_buffer)(d.display, image.buf) }; + packed + } + + /// What a derived image says about the surface's real layout, for the probe. + /// + /// Worth printing rather than assuming, and this is not idle: on `.25`'s + /// radeonsi the decode surfaces for every fixture here turned out to have NO + /// vertical padding — `offsets[1]` is exactly `pitch * height` — so the + /// chroma-plane trap that walk exists to avoid is not exercised by ANY hardware + /// leg on this driver. That is why `pf-vaadec`'s + /// `reading_chroma_at_the_display_height_would_have_been_caught` drives a + /// deliberately padded surface on the CPU: it is the only place that geometry is + /// checked at all, and a reader who assumed the hardware legs covered it would + /// be wrong. + fn describe(&self, d: &Display, surface: VaSurfaceId) -> String { + let mut image = pf_vaadec::VaImage::zeroed(); + // SAFETY: a live display and a surface from its own pool; `image` is a + // zeroed local of the measured layout that outlives the call. + let status = unsafe { (self.api.derive_image)(d.display, surface, &mut image) }; + if status != VA_STATUS_SUCCESS { + return format!("vaDeriveImage: {:#}", d.va.err("vaDeriveImage", status)); + } + let text = format!( + "{}x{}, {} plane(s), pitches {:?}, offsets {:?}, data_size {} — chroma \ + at pitch*height would be {}, so this surface is {}", + image.width, + image.height, + image.num_planes, + image.pitches, + image.offsets, + image.data_size, + image.pitches[0] * u32::from(image.height), + if image.offsets[1] == image.pitches[0] * u32::from(image.height) { + "NOT vertically padded (the crop trap is untested here)" + } else { + "vertically PADDED (the crop trap is live here)" + } + ); + // SAFETY: destroying the image this call derived, exactly once. + unsafe { (self.api.destroy_image)(d.display, image.image_id) }; + text + } + + /// `vaDeriveImage` — the surface's own memory, when the driver can address it + /// linearly. + fn read_via_derive( + &self, + d: &Display, + surface: VaSurfaceId, + display: (u32, u32), + fourcc: u32, + ) -> std::result::Result, String> { + let mut image = pf_vaadec::VaImage::zeroed(); + // SAFETY: a live display and a surface from its own pool; `image` is a + // zeroed local of the measured layout that outlives the call. + let status = unsafe { (self.api.derive_image)(d.display, surface, &mut image) }; + if status != VA_STATUS_SUCCESS { + return Err(format!("{:#}", d.va.err("vaDeriveImage", status))); + } + let out = self.read_mapped(d, &image, display, fourcc); + // SAFETY: destroying the image this call derived, exactly once. Required + // even on the failure path — the derive succeeded, so the image exists. + unsafe { (self.api.destroy_image)(d.display, image.image_id) }; + out + } + + /// Ensure the staging image is `size` in `fourcc`, creating or recreating it. + fn ensure_staging( + &mut self, + d: &Display, + size: (u32, u32), + fourcc: u32, + ) -> std::result::Result<(), String> { + if self + .staging + .as_ref() + .is_some_and(|s| s.size == size && s.fourcc == fourcc) + { + return Ok(()); + } + self.destroy_staging(d); + let mut format = *self + .formats + .iter() + .find(|f| f.fourcc == fourcc) + .ok_or_else(|| { + format!( + "this driver offers no VAImageFormat for the surface pool's own \ + fourcc; it offers [{}]", + self.offered() + ) + })?; + let mut image = pf_vaadec::VaImage::zeroed(); + // SAFETY: a live display; `format` and `image` are locals of the measured + // layouts that outlive the call, and libva copies the format it is handed. + let status = unsafe { + (self.api.create_image)( + d.display, + &mut format, + size.0 as c_int, + size.1 as c_int, + &mut image, + ) + }; + if status != VA_STATUS_SUCCESS { + return Err(format!("{:#}", d.va.err("vaCreateImage", status))); + } + self.staging = Some(Staging { + image, + size, + fourcc, + }); + Ok(()) + } + + fn destroy_staging(&mut self, d: &Display) { + if let Some(s) = self.staging.take() { + // SAFETY: an image this type created on this display, destroyed once. + unsafe { (self.api.destroy_image)(d.display, s.image.image_id) }; + } + } + + /// `vaCreateImage` + `vaGetImage` at one image size. + fn get_into( + &mut self, + d: &Display, + surface: VaSurfaceId, + size: (u32, u32), + display: (u32, u32), + fourcc: u32, + ) -> std::result::Result, String> { + self.ensure_staging(d, size, fourcc)?; + let image = self.staging.as_ref().expect("just ensured").image; + // SAFETY: a live display, a surface from its own pool and an image this + // type created on it. The region is inside the surface: `size` is either + // the picture (which the surface contains) or the surface itself. + let status = unsafe { + (self.api.get_image)( + d.display, + surface, + 0, + 0, + size.0 as c_uint, + size.1 as c_uint, + image.image_id, + ) + }; + if status != VA_STATUS_SUCCESS { + return Err(format!( + "{:#} (image {}x{})", + d.va.err("vaGetImage", status), + size.0, + size.1 + )); + } + self.read_mapped(d, &image, display, fourcc) + } + + /// `vaGetImage`, trying the picture-sized region first and the whole surface + /// second — a driver that refuses a sub-region still answers, and the crop then + /// happens in [`pf_vaadec::pack_two_plane`] instead. + fn read_via_get_image( + &mut self, + d: &Display, + surface: VaSurfaceId, + display: (u32, u32), + coded: (u32, u32), + fourcc: u32, + ) -> std::result::Result, String> { + if let Some(size) = self.get_size { + return self.get_into(d, surface, size, display, fourcc); + } + let mut sizes = vec![display]; + if coded != display { + sizes.push(coded); + } + let mut why = Vec::new(); + for size in sizes { + match self.get_into(d, surface, size, display, fourcc) { + Ok(bytes) => { + self.get_size = Some(size); + return Ok(bytes); + } + Err(e) => why.push(e), + } + } + Err(why.join("; ")) + } + + fn read_route( + &mut self, + route: Route, + d: &Display, + surface: VaSurfaceId, + display: (u32, u32), + coded: (u32, u32), + fourcc: u32, + ) -> std::result::Result, String> { + match route { + Route::Derive => { + let out = self.read_via_derive(d, surface, display, fourcc); + if out.is_ok() { + self.derived += 1; + } + out + } + Route::GetImage => { + let out = self.read_via_get_image(d, surface, display, coded, fourcc); + if out.is_ok() { + self.fetched += 1; + } + out + } + } + } + + /// The picture in `surface`, by whichever route this driver supports. + /// + /// Panics — loudly, with what every route said — when none of them can read it. + /// There is deliberately no skip: a leg that could not read a surface must + /// fail, not pass quietly (module docs). + fn read( + &mut self, + d: &Display, + surface: VaSurfaceId, + display: (u32, u32), + coded: (u32, u32), + fourcc: u32, + what: &str, + ) -> Vec { + // The surface must be finished before it is read. The production export + // does exactly this before the fds leave, and for the same reason: VAAPI + // exposes no fence to the consumer. + // + // SAFETY: a live display and a surface from its own pool. + let status = unsafe { (d.va.sync_surface)(d.display, surface) }; + if status != VA_STATUS_SUCCESS { + panic!("{what}: {:#}", d.va.err("vaSyncSurface", status)); + } + if let Some(route) = self.route { + return match self.read_route(route, d, surface, display, coded, fourcc) { + Ok(bytes) => bytes, + Err(e) => panic!( + "{what}: the {route:?} readback stopped working part-way through \ + a run — {e}" + ), + }; + } + let order = match self.forced { + Some(r) => vec![r], + None => vec![Route::Derive, Route::GetImage], + }; + let mut why = Vec::new(); + for route in order { + match self.read_route(route, d, surface, display, coded, fourcc) { + Ok(bytes) => { + eprintln!("readback route: {route:?}"); + self.route = Some(route); + return bytes; + } + Err(e) => why.push(format!("{route:?}: {e}")), + } + } + panic!( + "{what}: NO readback route could read the decoded surface, so this leg \ + can prove nothing and refuses to pass — {}. The driver offers image \ + formats [{}]", + why.join(" | "), + self.offered() + ); + } + + /// Read one surface through BOTH routes and require them to agree. + /// + /// The only check that can catch a `vaDeriveImage` which "succeeds" onto tiled + /// bytes: the descriptor looks ordinary, the walk reads it happily, and the + /// pixels are a permutation of the picture. `vaGetImage` asks the driver to + /// detile, so where both answer, agreement is evidence that derive's mapping + /// really is linear. + /// + /// A route that refuses is reported, not failed: that is exactly the case + /// [`Self::read`] is written to survive. + fn cross_check( + &mut self, + d: &Display, + surface: VaSurfaceId, + display: (u32, u32), + coded: (u32, u32), + fourcc: u32, + what: &str, + ) { + let derived = self.read_via_derive(d, surface, display, fourcc); + let fetched = self.read_via_get_image(d, surface, display, coded, fourcc); + match (&derived, &fetched) { + (Ok(a), Ok(b)) => { + assert_eq!( + a.len(), + b.len(), + "{what}: the two readback routes disagree on the picture's size" + ); + if a != b { + let diff = localise(a, b, display, fourcc); + panic!( + "{what}: vaDeriveImage and vaGetImage read DIFFERENT pixels \ + out of one surface — {diff}. Derive is handing back memory \ + this walk cannot address linearly (tiled or swizzled), so \ + every hash taken through it is meaningless. Re-run with \ + PF_VAAPI_READBACK=getimage" + ); + } + eprintln!("{what}: both readback routes agree ({} bytes)", a.len()); + } + (Ok(a), Err(e)) => eprintln!( + "{what}: vaDeriveImage answers ({} bytes); vaGetImage does not — {e}", + a.len() + ), + (Err(e), Ok(b)) => eprintln!( + "{what}: vaGetImage answers ({} bytes); vaDeriveImage does not — {e}", + b.len() + ), + (Err(a), Err(b)) => panic!( + "{what}: NEITHER readback route can read this surface — derive: {a} \ + | getimage: {b}. The driver offers image formats [{}]", + self.offered() + ), + } + } + + /// One line naming which route answered and how often, so a run says it rather + /// than leaving it to be inferred from a passing test. + fn summary(&self) -> String { + format!( + "readback via {:?} ({} derived, {} vaGetImage)", + self.route, self.derived, self.fetched + ) + } + } + + // ----------------------------------------------------------------------- + // Divergence: what a hash mismatch will not tell you + // ----------------------------------------------------------------------- + + /// Where two same-shaped pictures differ. + /// + /// "N frames differ" is not a lead; "first divergence at frame 3, one 16x24 luma + /// block, chroma clean" is what solved the last two defects in this program. This + /// is what turns the former into the latter wherever a reference picture exists — + /// [`AV1_FRAME0_NV12`] for AV1 display frame 0, the two readback routes against + /// each other, and the harness's own bytes in the counterfactual that proves the + /// comparison can fail. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct Divergence { + luma_samples: usize, + chroma_samples: usize, + /// Inclusive bounding box of the differing LUMA samples, in picture + /// coordinates. + luma_box: Option<(u32, u32, u32, u32)>, + max_delta: u32, + /// Ten-bit only: samples whose low six bits are set. P010 puts the ten + /// meaningful bits in the HIGH end of each 16-bit word, so a non-zero count + /// here means the driver handed back LSB-aligned samples and the divergence is + /// a format misunderstanding rather than a decode fault. + low_bits_set: usize, + } + + impl std::fmt::Display for Divergence { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.luma_samples == 0 && self.chroma_samples == 0 { + return write!(f, "identical"); + } + write!( + f, + "{} luma sample(s), {} chroma sample(s), max |delta| {}", + self.luma_samples, self.chroma_samples, self.max_delta + )?; + if let Some((x0, y0, x1, y1)) = self.luma_box { + write!( + f, + ", luma bounding box ({x0},{y0})..({x1},{y1}) = {}x{}", + x1 - x0 + 1, + y1 - y0 + 1 + )?; + } + if self.chroma_samples == 0 { + write!(f, ", chroma CLEAN")?; + } + if self.low_bits_set > 0 { + write!( + f, + ", and {} sample(s) have their low six bits set — P010's ten bits \ + belong in the HIGH end of each word, so suspect the FORMAT before \ + the decode", + self.low_bits_set + )?; + } + Ok(()) + } + } + + /// Compare two tightly packed pictures of the same shape. + fn localise(got: &[u8], want: &[u8], display: (u32, u32), fourcc: u32) -> Divergence { + let stride = if fourcc == pf_vaadec::VA_FOURCC_P010 { + 2usize + } else { + 1 + }; + let (width, height) = (display.0 as usize, display.1 as usize); + let luma_bytes = width * height * stride; + let sample = |buf: &[u8], at: usize| -> u32 { + if stride == 2 { + u32::from(u16::from_le_bytes([buf[at], buf[at + 1]])) + } else { + u32::from(buf[at]) + } + }; + let mut d = Divergence { + luma_samples: 0, + chroma_samples: 0, + luma_box: None, + max_delta: 0, + low_bits_set: 0, + }; + let end = got.len().min(want.len()); + let mut at = 0usize; + while at + stride <= end { + let (a, b) = (sample(got, at), sample(want, at)); + if stride == 2 && a & 0x3f != 0 { + d.low_bits_set += 1; + } + if a != b { + d.max_delta = d.max_delta.max(a.abs_diff(b)); + if at < luma_bytes { + d.luma_samples += 1; + let index = at / stride; + let (x, y) = ((index % width) as u32, (index / width) as u32); + d.luma_box = Some(match d.luma_box { + None => (x, y, x, y), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)), + }); + } else { + d.chroma_samples += 1; + } + } + at += stride; + } + d + } + + // ----------------------------------------------------------------------- + // Decode and display order, from a planner run alongside the decoder's own + // ----------------------------------------------------------------------- + + /// The decode order and the display order of a stream's pictures, as `PicId`s. + /// + /// Both come from a planner run ALONGSIDE the rung's own, over the same access + /// units: the planner is deterministic, so the ids it hands this walk are the ids + /// it hands the rung, and no production code has to grow a test accessor. + /// + /// The hardware legs do not USE this to find surfaces — they hash what the rung + /// delivers, in delivery order (module docs). It is what the CPU guards check the + /// golden files and the frame counts against, so a regenerated vector fails on a + /// laptop rather than on the one box with a VAAPI driver. + struct Order { + /// Every DECODED picture in submission order — one per access unit on + /// H.264/H.265, one per FRAME on AV1 where a unit may carry several. + decode: Vec, + /// The same ids in the planner's output (bumping) order, flush included. + display: Vec, + /// The ids each ACCESS UNIT decodes, in submission order. An access unit that + /// decodes nothing — an HEVC RASL skipped after an open-GOP join — contributes + /// an EMPTY entry rather than none, so the index stays the unit's own. + per_unit: Vec>, + } + + impl Order { + fn empty() -> Order { + Order { + decode: Vec::new(), + display: Vec::new(), + per_unit: Vec::new(), + } + } + } + + fn order_h264(aus: &[&[u8]]) -> Order { + let mut planner = pf_vaadec::H264Planner::new(); + let mut order = Order::empty(); + 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:?}")); + assert_eq!( + (plan.picture.display_crop.x, plan.picture.display_crop.y), + (0, 0), + "AU {index}: this rung REFUSES a non-zero conformance-window origin \ + (`shape_of`), so a vector that had one could not be decoded here at all" + ); + let id = plan + .dpb + .stored + .unwrap_or_else(|| panic!("AU {index}: every picture of this vector is stored")); + order.decode.push(id); + order.per_unit.push(vec![id]); + order.display.extend(plan.dpb.outputs.iter().copied()); + } + order.display.extend(planner.flush().outputs); + order + } + + /// [`order_h264`] for HEVC, with the one thing H.264 has no counterpart to: a + /// **RASL picture skipped after an open-GOP join** decodes nothing. + /// + /// `PlanErrorH265::RaslSkipped` is the spec's own answer (8.1.3 NOTE) and the rung + /// treats it as an Ok-skip, so such an access unit contributes no picture and no + /// output. + fn order_h265(aus: &[&[u8]]) -> Order { + let mut planner = pf_vaadec::H265Planner::new(); + let mut order = Order::empty(); + for (index, au) in aus.iter().enumerate() { + let plan = match planner.plan_au(au) { + Ok(plan) => plan, + Err(pf_vaadec::PlanErrorH265::RaslSkipped { .. }) => { + order.per_unit.push(Vec::new()); + continue; + } + Err(e) => panic!("AU {index}: the clean vector must plan, got {e:?}"), + }; + assert_eq!( + (plan.picture.display_crop.x, plan.picture.display_crop.y), + (0, 0), + "AU {index}: this rung refuses a non-zero conformance-window origin" + ); + let id = plan + .dpb + .stored + .unwrap_or_else(|| panic!("AU {index}: every picture of this vector is stored")); + order.decode.push(id); + order.per_unit.push(vec![id]); + order.display.extend(plan.dpb.outputs.iter().copied()); + } + order.display.extend(planner.flush().outputs); + order + } + + /// The AV1 stream's decode and display orders. + /// + /// Where the H.264/H.265 walks push one decoded picture per access unit, this one + /// pushes one per FRAME and a unit may carry several — which is the whole + /// difference. `display` is still the planner's own output list; AV1 has no bumping + /// process, so a picture is output by the unit that shows it and there is no flush + /// to drain at the end. + fn order_av1(units: &[&[u8]], render: (u32, u32)) -> Order { + let mut planner = pf_vaadec::Av1Planner::new(); + let mut order = Order::empty(); + for (index, unit) in units.iter().enumerate() { + let plans = planner + .plan_au(unit) + .unwrap_or_else(|e| panic!("unit {index}: the clean vector must plan, got {e}")); + let mut this_unit = Vec::new(); + for plan in &plans { + assert!( + plan.warnings.is_empty(), + "unit {index}: a clean vector must plan without warnings, got {:?}", + plan.warnings + ); + assert_eq!( + (plan.picture.render_width, plan.picture.render_height), + render, + "unit {index}: the goldens are the {render:?} render region" + ); + if let Some(id) = plan.dpb.stored { + order.decode.push(id); + this_unit.push(id); + } + order.display.extend(plan.dpb.outputs.iter().copied()); + } + order.per_unit.push(this_unit); + } + order + } + + // ----------------------------------------------------------------------- + // The runs + // ----------------------------------------------------------------------- + + /// Read back the picture a delivered frame carries, packed as the goldens hash it. + /// + /// The frame names its own surface — [`VaRelease::surface`], stamped when `finish` + /// exported it — so nothing here has to work out which pool entry holds which + /// picture. It also carries its own display region and fourcc, recorded when the + /// picture DECODED (`PictureFacts`), which is the same pair the presenter is handed; + /// reading them from anywhere else would be a second guess that could differ. + fn read_frame( + decoder: &NativeVaapiDecoder, + readback: &mut Readback, + frame: &DmabufFrame, + what: &str, + ) -> Vec { + let release = frame.guard.0.release; + let (surface, coded, fourcc) = { + let s = decoder + .session + .as_ref() + .unwrap_or_else(|| panic!("{what}: a frame came back with no session behind it")); + assert_eq!( + release.generation, s.generation, + "{what}: this frame names a RETIRED surface pool, so its pixels are not \ + this session's — nothing in these vectors renegotiates, so a mismatch \ + here is a bookkeeping defect rather than a stream that resized" + ); + ( + s.surfaces[release.surface], + (s.shape.coded_width, s.shape.coded_height), + s.fourcc, + ) + }; + assert_eq!( + frame.fourcc, fourcc, + "{what}: the frame's fourcc is not the pool's — `finish` is supposed to \ + refuse that before it ships" + ); + let display = (frame.width, frame.height); + // The first frame of a leg is read through BOTH routes, and they must agree. + // Skipped when a route was PINNED: the pin exists precisely for a box where one + // of them is wrong, and failing the run because it is wrong would defeat it. + if readback.route.is_none() && readback.forced.is_none() { + readback.cross_check(&decoder.display, surface, display, coded, fourcc, what); + } + let bytes = readback.read(&decoder.display, surface, display, coded, fourcc, what); + assert_eq!( + bytes.len(), + pf_vaadec::packed_len(display, fourcc).expect("the pool's fourcc is one of ours"), + "{what}: the readback is not the golden's own layout" + ); + bytes + } + + /// Compare the hash of every DELIVERED frame against the goldens, in order, + /// printing the first ten divergences and returning how many there were and where + /// the first one is. + /// + /// A separate function from the run that produces the hashes so it can be driven + /// from a CPU test with a deliberately corrupted list — + /// [`the_comparison_catches_a_corrupted_frame`] is that counterfactual, and it is + /// the answer to "prove this harness can fail". + fn compare(hashes: &[String], goldens: &[&str], label: &str) -> (usize, Option) { + let mut mismatches = 0usize; + let mut first = None; + for (n, (got, golden)) in hashes.iter().zip(goldens.iter()).enumerate() { + if got != golden { + if mismatches < 10 { + eprintln!("{label}: display frame {n}: {got} != {golden}"); + } + if first.is_none() { + first = Some(n); + } + mismatches += 1; + } + } + (mismatches, first) + } + + /// The verdict, spelled the way the last two defects were localised from. + fn verdict( + mismatches: usize, + first: Option, + total: usize, + label: &str, + readback: &str, + opening: &str, + ) { + assert_eq!( + mismatches, 0, + "{label}: {mismatches}/{total} frames diverge from libavcodec's software \ + decode (first ten above; first divergence at display frame {first:?}). \ + {opening} Read the signature as evidence about WHERE, not about WHAT — the \ + D3D11VA AV1 defect had two unlike signatures on two vendors and was ONE \ + bug. Readback was {readback}; PF_VAAPI_READBACK=getimage forces the copying \ + route, and PF_VAAPI_DUMP= writes the raw planes" + ); + eprintln!( + "{label}: {total} frames bit-identical to libavcodec software decode ({readback})" + ); + } + + /// Write one frame's raw planes to the temp directory when `PF_VAAPI_DUMP` is set — + /// the lever that turned "186 frames differ" into a located defect on the D3D11VA + /// rung, by giving `ffmpeg -f rawvideo` something to compare against. + fn dump(tag: &Option, label: &str, what: &str, bytes: &[u8]) { + let Some(tag) = tag else { return }; + let path = std::env::temp_dir().join(format!( + "pf-vaapi-{tag}-{}-{what}.bin", + label.replace([' ', '(', ')', ',', '.'], "") + )); + std::fs::write(&path, bytes).expect("write the dump"); + eprintln!("dumped {what} -> {}", path.display()); + } + + /// Everything a run collects, so the two drivers below can share the assertions + /// that matter rather than two hand-copied sets. + struct Delivered { + hashes: Vec, + /// The first delivered frame's keyframe flag — a fact about the DELIVERY path + /// that used to be wrong on every reordering stream. + first_keyframe: bool, + /// The first delivered frame's pixels, for the one leg that has libavcodec's. + first_bytes: Vec, + /// How many frames the deliverable queue had to DROP. Anything but zero is a + /// golden that can never be checked. + dropped: u64, + } + + /// Drive one stream's access units through a real rung, hashing every frame it + /// hands back, then drain the tail. + /// + /// The tail is not optional and not bookkeeping: `flush` is where the pictures the + /// DPB is still buffering for reorder come from — seven of the H.264 vector's 250, + /// two of the HEVC vector's, two of Main 10's — and a run that stopped at the last + /// access unit would be exactly that many frames short of the goldens, which reads + /// like missing pictures rather than like a harness that never asked. + fn drive( + decoder: &mut NativeVaapiDecoder, + readback: &mut Readback, + units: &[&[u8]], + label: &str, + ) -> Delivered { + let mut hashes = Vec::new(); + let mut first_keyframe = false; + let mut first_bytes = Vec::new(); + for (index, unit) in units.iter().enumerate() { + let frame = decoder + .decode(unit) + .unwrap_or_else(|e| panic!("{label} AU {index}: decode failed — {e:#}")); + if let Some(frame) = frame { + let what = format!("{label} AU {index} -> display frame {}", hashes.len()); + let bytes = read_frame(decoder, readback, &frame, &what); + if hashes.is_empty() { + first_keyframe = frame.keyframe; + first_bytes = bytes.clone(); + } + hashes.push(sha256_hex(&bytes)); + } + } + let tail = decoder.flush(); + eprintln!("{label}: {} frame(s) came out of the flush", tail.len()); + for frame in &tail { + let what = format!("{label} flush -> display frame {}", hashes.len()); + let bytes = read_frame(decoder, readback, frame, &what); + if hashes.is_empty() { + first_keyframe = frame.keyframe; + first_bytes = bytes.clone(); + } + hashes.push(sha256_hex(&bytes)); + } + Delivered { + hashes, + first_keyframe, + first_bytes, + dropped: decoder.health().dropped, + } + } + + /// The assertions every leg makes about what came back, before a single hash is + /// compared. + fn check_delivery(d: &Delivered, goldens: &[&str], label: &str) { + assert_eq!( + d.dropped, 0, + "{label}: the rung DROPPED {} display-ready frame(s) because its deliverable \ + queue overflowed. Every one of them is a golden that can never be checked, \ + so the comparison below would be measuring a shorter stream than the \ + goldens describe", + d.dropped + ); + assert_eq!( + d.hashes.len(), + goldens.len(), + "{label}: the rung delivered {} frames and the goldens carry {}. This is the \ + delivery path, not the decode: the rung must hand back every picture the \ + planner outputs, `flush` included", + d.hashes.len(), + goldens.len() + ); + assert!( + d.first_keyframe, + "{label}: the FIRST delivered frame is not flagged as a keyframe. Every one \ + of these streams opens on an IDR or an AV1 key frame, and that frame is the \ + first thing displayed — a rung that labels the access unit rather than the \ + picture it displays gets this wrong on any stream that reorders, and \ + `DecodedImage::is_keyframe` is the pump's post-loss re-anchor signal" + ); + } + + /// Decode `aus` through a real [`NativeVaapiDecoder`] and compare every delivered + /// frame against libavcodec's goldens. + fn parity_run( + codec: pf_vaadec::Codec, + stream: StreamFormat, + aus: &[&[u8]], + order: &Order, + goldens: &[&str], + expected_aus: usize, + label: &str, + ) { + assert_eq!( + aus.len(), + expected_aus, + "{label}: the vector must split into {expected_aus} access units — a \ + different count means this file's splitter disagrees with pf-bitstream's, \ + and nothing below it is meaningful" + ); + assert_eq!( + order.display.len(), + goldens.len(), + "{label}: the planner outputs {} pictures, the goldens carry {}", + order.display.len(), + goldens.len() + ); + + let mut decoder = NativeVaapiDecoder::new(codec, stream) + .unwrap_or_else(|e| panic!("{label}: this box must host this profile — {e:#}")); + let mut readback = Readback::new(&decoder.display); + let dump_tag = std::env::var("PF_VAAPI_DUMP").ok(); + + let delivered = drive(&mut decoder, &mut readback, aus, label); + dump(&dump_tag, label, "display0", &delivered.first_bytes); + check_delivery(&delivered, goldens, label); + + let (mismatches, first) = compare(&delivered.hashes, goldens, label); + let readback_note = readback.summary(); + readback.destroy_staging(&decoder.display); + verdict( + mismatches, + first, + goldens.len(), + label, + &readback_note, + "Display frame 0 is intra-only — if IT diverges suspect the readback \ + geometry (pitch, crop, plane offset) or the surface format rather than the \ + decode.", + ); + } + + /// The AV1 leg of [`parity_run`]. + /// + /// It drives the same production entry point — [`NativeVaapiDecoder::decode`] takes + /// a whole temporal unit, exactly as the stream does — so the unit loop, + /// `frame_av1`'s slot bookkeeping and the `show` suppression are all under test. Two + /// things make it a separate function rather than a parameter: + /// + /// * a temporal unit is not a picture. 24 of the vendored vector's 250 units carry a + /// HIDDEN frame as well as the shown one, so 274 pictures decode and 250 display, + /// and this leg asserts that gap from the planner rather than assuming it; + /// * it has libavcodec's actual PIXELS for display frame 0 ([`AV1_FRAME0_NV12`]), + /// which is the only place in this program a divergence can be localised without + /// a second GPU to compare against. + #[allow(clippy::too_many_arguments)] + fn av1_parity_run( + units: &[&[u8]], + order: &Order, + goldens: &[&str], + unit_count: usize, + decoded_count: usize, + shown_count: usize, + frame0_golden: Option<&[u8]>, + label: &str, + ) { + assert_eq!( + units.len(), + unit_count, + "{label}: the IVF reader disagrees with the stream's temporal-unit count" + ); + assert_eq!(order.decode.len(), decoded_count); + assert_eq!(order.per_unit.len(), units.len()); + assert_eq!(order.display.len(), goldens.len()); + assert_eq!(order.display.len(), shown_count); + + let mut decoder = NativeVaapiDecoder::new(pf_vaadec::Codec::Av1, StreamFormat::SDR_420_8) + .unwrap_or_else(|e| panic!("{label}: this box must host AV1 Profile 0 — {e:#}")); + let mut readback = Readback::new(&decoder.display); + let dump_tag = std::env::var("PF_VAAPI_DUMP").ok(); + + let delivered = drive(&mut decoder, &mut readback, units, label); + dump(&dump_tag, label, "display0", &delivered.first_bytes); + check_delivery(&delivered, goldens, label); + + let hidden = decoded_count - shown_count; + assert_eq!( + delivered.hashes.len(), + shown_count, + "{label}: {decoded_count} pictures decode and {shown_count} display, so \ + {hidden} must have been decoded and WITHHELD. On a stream with no hidden \ + frames both sides are equal and this is a tautology — deliberately, so one \ + harness serves both shapes" + ); + + // The one place in this program where a divergence can be localised without a + // second GPU to compare against: libavcodec's own first frame, as pixels. + if let Some(golden) = frame0_golden { + if delivered.first_bytes.as_slice() != golden { + let diff = localise( + &delivered.first_bytes, + golden, + DISPLAY_AV1, + pf_vaadec::VA_FOURCC_NV12, + ); + panic!( + "{label}: display frame 0 does not match libavcodec's own pixels — \ + {diff}. It is a KEY frame with no references, so this is readback \ + geometry, the surface format, or the tile records — never reference \ + handling" + ); + } + eprintln!("{label}: display frame 0 is byte-identical to libavcodec's pixels"); + } + + let (mismatches, first) = compare(&delivered.hashes, goldens, label); + let readback_note = readback.summary(); + readback.destroy_staging(&decoder.display); + verdict( + mismatches, + first, + goldens.len(), + label, + &readback_note, + &format!( + "{hidden} hidden frame(s) were decoded and withheld. Display frame 0 is a \ + key frame — if IT diverges suspect the readback geometry or the tile \ + records rather than the reference handling." + ), + ); + } + + // ----------------------------------------------------------------------- + // The legs + // ----------------------------------------------------------------------- + + #[test] + #[ignore = "needs a machine with a libva runtime and an H.264 VLD entry point"] + fn h264_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h264_aus(H264_25FPS); + let order = order_h264(&aus); + parity_run( + pf_vaadec::Codec::H264, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_H264), + H26X_AU_COUNT, + "H.264", + ); + } + + /// **Our own host's output** rather than a conformance vector — the shape that + /// caught the D3D11VA rung's release-ordering defect after the vector had passed + /// 250/250 on four GPUs across two milestones. + /// + /// See [`LOWDELAY_H264`] for why this rung is argued exempt from that defect, and + /// why the argument being good is not the same as its having been checked. + #[test] + #[ignore = "needs a machine with a libva runtime and an H.264 VLD entry point"] + fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h264_aus(LOWDELAY_H264); + let order = order_h264(&aus); + parity_run( + pf_vaadec::Codec::H264, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_LOWDELAY_H264), + LOWDELAY_H264_AU_COUNT, + "H.264 (low-delay host stream)", + ); + } + + #[test] + #[ignore = "needs a machine with a libva runtime and an HEVC Main VLD entry point"] + fn h265_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h265_aus(H265_25FPS); + let order = order_h265(&aus); + parity_run( + pf_vaadec::Codec::H265, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_H265), + H26X_AU_COUNT, + "H.265", + ); + } + + #[test] + #[ignore = "needs a machine with a libva runtime and an HEVC Main VLD entry point"] + fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h265_aus(LOWDELAY_H265); + let order = order_h265(&aus); + parity_run( + pf_vaadec::Codec::H265, + StreamFormat::SDR_420_8, + &aus, + &order, + &golden_hashes(GOLDENS_LOWDELAY_H265), + LOWDELAY_H265_AU_COUNT, + "H.265 (low-delay host stream)", + ); + } + + /// The ten-bit path, which every HDR session lands on. + /// + /// It exercises geometry the 8-bit legs cannot: **P010 samples are two bytes**, so a + /// row is `width * 2`, and HEVC's granule pads a 240-line picture to a 256-line + /// surface — the chroma plane therefore starts a long way from where the display + /// height would put it. And it is the only leg that can tell a Main 10 session that + /// BUILDS from one that decodes correctly: VAAPI has no per-picture decode status + /// query, so a stream decoding to garbage logs exactly as cleanly. + #[test] + #[ignore = "needs a machine with a libva runtime and an HEVC Main 10 VLD entry point"] + fn main10_every_frame_hashes_bit_identical_to_libavcodec() { + let aus = split_h265_aus(MAIN10_H265); + let order = order_h265(&aus); + parity_run( + pf_vaadec::Codec::H265, + StreamFormat { + bit_depth: 10, + ..StreamFormat::SDR_420_8 + }, + &aus, + &order, + &golden_hashes(GOLDENS_MAIN10), + MAIN10_AU_COUNT, + "HEVC Main 10", + ); + } + + #[test] + #[ignore = "needs a machine with a libva runtime and an AV1 VLD entry point"] + fn av1_every_delivered_frame_hashes_bit_identical_to_libavcodec() { + let units = split_ivf(AV1_25FPS); + let order = order_av1(&units, DISPLAY_AV1); + av1_parity_run( + &units, + &order, + &golden_hashes(GOLDENS_AV1), + AV1_UNIT_COUNT, + AV1_DECODED_COUNT, + AV1_SHOWN_COUNT, + Some(AV1_FRAME0_NV12), + "AV1", + ); + } + + /// **Our own host's AV1, at the only resolution where it emits more than one tile.** + /// The leg above runs a vector whose every frame is `tile_cols = tile_rows = 1`, so + /// every tile field `plan_to_va_av1` fills is the degenerate case. This stream is + /// `tile_rows = 2` on all 60 frames with both tiles in one Tile Group OBU — and it + /// is 4K, so the readback moves 12.4 MB per frame. + #[test] + #[ignore = "needs a machine with a libva runtime and an AV1 VLD entry point"] + fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() { + let units = split_ivf(LOWDELAY_AV1); + let order = order_av1(&units, DISPLAY_LOWDELAY_AV1); + av1_parity_run( + &units, + &order, + &golden_hashes(GOLDENS_LOWDELAY_AV1), + LOWDELAY_AV1_UNIT_COUNT, + LOWDELAY_AV1_DECODED_COUNT, + LOWDELAY_AV1_SHOWN_COUNT, + // The raw golden is the vendored vector's frame 0, at 320x240. This stream + // is 4K, so there is nothing to compare pixels against. + None, + "AV1 (low-delay host stream, 4K two-tile)", + ); + } + + // ----------------------------------------------------------------------- + // The harness's own evidence: that it reads real pixels and CAN fail + // ----------------------------------------------------------------------- + + /// Which readback routes this machine's driver supports, on a real decoded surface, + /// and what they hand back. + /// + /// A diagnostic, not a gate — but it FAILS rather than skips if neither route works, + /// because a box someone deliberately pointed this at is a box that is supposed to + /// be able to answer. + #[test] + #[ignore = "needs a machine with a libva runtime and an H.264 VLD entry point"] + fn probe_this_machines_readback_routes() { + let aus = split_h264_aus(H264_25FPS); + let mut decoder = NativeVaapiDecoder::new(pf_vaadec::Codec::H264, StreamFormat::SDR_420_8) + .expect("this box is supposed to have a VAAPI H.264 decode entry point"); + let mut frame = None; + for (index, au) in aus.iter().enumerate() { + frame = decoder + .decode(au) + .unwrap_or_else(|e| panic!("AU {index}: decode failed — {e:#}")); + if frame.is_some() { + break; + } + } + let frame = frame.expect("some access unit of the vendored vector must deliver a frame"); + let release = frame.guard.0.release; + let (surface, display, coded, fourcc) = { + let s = decoder.session.as_ref().expect("a session"); + ( + s.surfaces[release.surface], + (frame.width, frame.height), + (s.shape.coded_width, s.shape.coded_height), + s.fourcc, + ) + }; + let mut readback = Readback::new(&decoder.display); + eprintln!("driver image formats: [{}]", readback.offered()); + eprintln!("surface {surface:#x}: picture {display:?} in a {coded:?} pool"); + eprintln!( + "derived layout: {}", + readback.describe(&decoder.display, surface) + ); + // SAFETY: a live display and a surface from its own pool. + let status = unsafe { (decoder.display.va.sync_surface)(decoder.display.display, surface) }; + assert_eq!(status, VA_STATUS_SUCCESS, "vaSyncSurface"); + for route in [Route::Derive, Route::GetImage] { + match readback.read_route(route, &decoder.display, surface, display, coded, fourcc) { + Ok(bytes) => eprintln!(" {route:?}: {} bytes", bytes.len()), + Err(e) => eprintln!(" {route:?}: NO — {e}"), + } + } + readback.cross_check( + &decoder.display, + surface, + display, + coded, + fourcc, + "readback probe", + ); + assert!( + readback.derived > 0 || readback.fetched > 0, + "neither readback route works on this device — parity is impossible here, \ + and saying so is the point of this probe" + ); + readback.destroy_staging(&decoder.display); + } + + /// **The counterfactual on hardware**: the readback reads real, distinct pixels, and + /// the comparison the legs use catches a frame that is wrong by one byte. + /// + /// Three tests in this program have been found asserting nothing, so the parity legs + /// owe a proof that they can fail. This is it, driven through the same [`Readback`], + /// the same [`sha256_hex`], the same [`localise`] and the same [`compare`] the legs + /// use: + /// + /// * a readback that returned zeros, or the same surface every time, would make + /// every hash equal — so two different pictures must hash differently; + /// * a readback that returned a constant would still have the right LENGTH — so the + /// picture must not be one repeated byte; + /// * and one flipped byte must be caught, and LOCALISED to the pixel. + #[test] + #[ignore = "needs a machine with a libva runtime and an H.264 VLD entry point"] + fn the_readback_reads_real_pixels_and_the_comparison_can_fail() { + let aus = split_h264_aus(H264_25FPS); + let goldens = golden_hashes(GOLDENS_H264); + let mut decoder = NativeVaapiDecoder::new(pf_vaadec::Codec::H264, StreamFormat::SDR_420_8) + .expect("this box is supposed to have a VAAPI H.264 decode entry point"); + let mut readback = Readback::new(&decoder.display); + + let mut frames: Vec> = Vec::new(); + for (index, au) in aus.iter().take(20).enumerate() { + let frame = decoder.decode(au).expect("the clean vector decodes"); + let Some(frame) = frame else { continue }; + let what = format!("counterfactual AU {index}"); + let bytes = read_frame(&decoder, &mut readback, &frame, &what); + assert!( + bytes.iter().any(|b| *b != bytes[0]), + "{what}: the readback handed back {} identical bytes — that is an \ + unwritten or unmapped surface, not a picture", + bytes.len() + ); + frames.push(bytes); + } + readback.destroy_staging(&decoder.display); + assert!( + frames.len() >= 2, + "the first twenty access units must deliver at least two pictures" + ); + + let hashes: Vec = frames.iter().map(|b| sha256_hex(b)).collect(); + let distinct: std::collections::HashSet<&String> = hashes.iter().collect(); + assert!( + distinct.len() > 1, + "{} decoded pictures produced ONE hash — the readback is reading the same \ + surface, or the same bytes, every time", + hashes.len() + ); + let here: Vec<&str> = goldens[..hashes.len()].to_vec(); + assert_eq!( + compare(&hashes, &here, "counterfactual"), + (0, None), + "the first {} display frames must already agree with libavcodec, or this \ + test is measuring a defect rather than its own falsifiability", + hashes.len() + ); + + // Now the corruption. One luma byte in the LAST frame checked, and the + // comparison must name that frame and only that frame. + let victim = hashes.len() - 1; + let mut corrupted = frames[victim].clone(); + // The luma sample at the dead centre of this vector's 320x240 picture, so the + // bounding box is a statement about a PIXEL rather than about the first byte of + // the buffer or the edge of a plane. + let at = 120 * 320 + 160; + corrupted[at] ^= 0x01; + let diff = localise( + &corrupted, + &frames[victim], + (320, 240), + pf_vaadec::VA_FOURCC_NV12, + ); + assert_eq!( + diff.luma_samples, 1, + "one flipped luma byte, one differing sample" + ); + assert_eq!(diff.chroma_samples, 0, "chroma must read CLEAN"); + assert_eq!(diff.max_delta, 1); + assert_eq!( + diff.luma_box, + Some((160, 120, 160, 120)), + "the divergence must be localised to the pixel that was flipped" + ); + + let mut dirty = hashes.clone(); + dirty[victim] = sha256_hex(&corrupted); + assert_eq!( + compare(&dirty, &here, "counterfactual"), + (1, Some(victim)), + "the comparison every leg uses must catch a one-byte corruption, and name \ + which display frame carries it" + ); + } + + // ----------------------------------------------------------------------- + // CPU guards — NOT `#[ignore]`d, so ordinary CI notices drift + // ----------------------------------------------------------------------- + + /// The invariant the module docs rest on, asserted mechanically: **the surface + /// readback's libva entry points are resolved only inside this test module.** + /// + /// Not a stylistic preference. A per-frame `vaMapBuffer` on the production video + /// path would be exactly the copy zero-copy exists to avoid, and this project has a + /// standing rule against it. Reading this file's own source is what turns "we were + /// careful" into something a refactor cannot quietly undo: the production [`Libva`] + /// must resolve none of these, and this module must resolve all of them. + /// + /// Doc comments elsewhere in the file name the same calls in prose; the scan looks + /// for the QUOTED symbol strings a `dlsym` needs, which prose never contains. + #[test] + fn the_readback_entry_points_are_resolved_only_inside_this_module() { + const SOURCE: &str = include_str!("video_vaapi_native.rs"); + const MARKER: &str = "mod parity {"; + let at = SOURCE + .find(MARKER) + .expect("this module's own header is in this module's own file"); + let (production, harness) = SOURCE.split_at(at); + for symbol in [ + "\"vaDeriveImage\"", + "\"vaCreateImage\"", + "\"vaGetImage\"", + "\"vaDestroyImage\"", + "\"vaMapBuffer\"", + "\"vaUnmapBuffer\"", + "\"vaQueryImageFormats\"", + "\"vaMaxNumImageFormats\"", + ] { + assert!( + !production.contains(symbol), + "{symbol} is resolved OUTSIDE the `#[cfg(test)] mod parity` block. The \ + surface readback is a test-only facility: a shipped build must not be \ + able to map a decode surface at all, which is what keeps the zero-copy \ + guarantee structural rather than a promise" + ); + assert!( + harness.contains(symbol), + "{symbol} is no longer resolved by the parity harness — if the readback \ + moved, this guard has to move with it or it protects nothing" + ); + } + } + + /// The planners' display orders match the golden sets, on every one of the seven + /// streams these legs decode — checked on CPU so a regenerated vector or a change in + /// the bumping process fails here rather than as a mysterious hardware failure on a + /// machine somebody had to walk to. + #[test] + fn every_golden_set_matches_its_planners_display_order() { + for (label, order, goldens) in [ + ( + "H.264", + order_h264(&split_h264_aus(H264_25FPS)), + golden_hashes(GOLDENS_H264), + ), + ( + "H.264 low-delay", + order_h264(&split_h264_aus(LOWDELAY_H264)), + golden_hashes(GOLDENS_LOWDELAY_H264), + ), + ( + "H.265", + order_h265(&split_h265_aus(H265_25FPS)), + golden_hashes(GOLDENS_H265), + ), + ( + "H.265 low-delay", + order_h265(&split_h265_aus(LOWDELAY_H265)), + golden_hashes(GOLDENS_LOWDELAY_H265), + ), + ( + "HEVC Main 10", + order_h265(&split_h265_aus(MAIN10_H265)), + golden_hashes(GOLDENS_MAIN10), + ), + ( + "AV1", + order_av1(&split_ivf(AV1_25FPS), DISPLAY_AV1), + golden_hashes(GOLDENS_AV1), + ), + ( + "AV1 low-delay 4K", + order_av1(&split_ivf(LOWDELAY_AV1), DISPLAY_LOWDELAY_AV1), + golden_hashes(GOLDENS_LOWDELAY_AV1), + ), + ] { + assert_eq!( + order.display.len(), + goldens.len(), + "{label}: the planner outputs {} pictures and the golden file carries {}", + order.display.len(), + goldens.len() + ); + assert!( + order.decode.len() >= order.display.len(), + "{label}: a picture cannot be displayed without being decoded" + ); + for id in &order.display { + assert!( + order.decode.contains(id), + "{label}: display order names PicId {id}, which nothing decodes — the \ + hardware legs would fail on this with a message about the rung" + ); + } + assert_eq!( + goldens.len(), + goldens + .iter() + .collect::>() + .len(), + "{label}: two display frames carry the SAME golden hash. That is not \ + impossible in principle, but on these vectors it would mean the golden \ + file was generated from a stream that repeated a frame — and a parity \ + leg cannot tell a correctly repeated frame from a rung that delivered \ + one picture twice" + ); + } + } + + /// The counts the AV1 legs assert are what the PLANNER implies, and the two AV1 + /// streams really are the opposite shapes their constants claim. + #[test] + fn the_two_av1_streams_are_the_opposite_shapes_the_legs_claim() { + let vendored = order_av1(&split_ivf(AV1_25FPS), DISPLAY_AV1); + assert_eq!(vendored.per_unit.len(), AV1_UNIT_COUNT); + assert_eq!(vendored.decode.len(), AV1_DECODED_COUNT); + assert_eq!(vendored.display.len(), AV1_SHOWN_COUNT); + assert_eq!( + vendored.per_unit.iter().filter(|u| u.len() > 1).count(), + AV1_DECODED_COUNT - AV1_SHOWN_COUNT, + "24 units must carry a hidden frame as well as the shown one — without them \ + the AV1 leg proves nothing the H.264 leg does not already prove" + ); + + let ours = order_av1(&split_ivf(LOWDELAY_AV1), DISPLAY_LOWDELAY_AV1); + assert_eq!(ours.per_unit.len(), LOWDELAY_AV1_UNIT_COUNT); + assert_eq!(ours.decode.len(), LOWDELAY_AV1_DECODED_COUNT); + assert_eq!(ours.display.len(), LOWDELAY_AV1_SHOWN_COUNT); + assert!( + ours.per_unit.iter().all(|u| u.len() == 1), + "our host emits one frame per temporal unit and no hidden frames — the \ + OPPOSITE shape to the vendored vector, which is why both legs exist" + ); + } + + /// Both vendored H.26x vectors REORDER, and our own streams do not. + /// + /// The first half is why the legs need `flush` and why delivery order is a claim + /// worth checking at all; the second is why our fixtures represent what punktfunk + /// actually streams. Asserted so neither claim can go stale. + #[test] + fn the_vendored_vectors_reorder_and_our_own_streams_do_not() { + for (label, order) in [ + ("H.264", order_h264(&split_h264_aus(H264_25FPS))), + ("H.265", order_h265(&split_h265_aus(H265_25FPS))), + ] { + assert_ne!( + order.decode, order.display, + "{label}: this vector no longer reorders — the tail `flush` drains would \ + then be empty and these legs would stop covering the reordering path" + ); + } + for (label, order) in [ + ( + "H.264 low-delay", + order_h264(&split_h264_aus(LOWDELAY_H264)), + ), + ( + "H.265 low-delay", + order_h265(&split_h265_aus(LOWDELAY_H265)), + ), + ] { + assert_eq!( + order.decode, order.display, + "{label}: our host emits zero-reorder output, so decode order IS display \ + order — if that stops being true these fixtures no longer represent \ + what punktfunk streams" + ); + } + } + + /// **The counterfactual, on CPU**: the comparison every leg's verdict rests on + /// catches a wrong frame and names it. + /// + /// Runs on macOS and in the container with no device, so the falsifiability of the + /// parity legs is checked by ordinary CI rather than only on the one box that has a + /// VAAPI driver. + #[test] + fn the_comparison_catches_a_corrupted_frame() { + let goldens = ["aa", "bb", "cc"]; + let clean: Vec = goldens.iter().map(|g| (*g).to_string()).collect(); + assert_eq!( + compare(&clean, &goldens, "cpu"), + (0, None), + "an agreeing set must report no divergence" + ); + + let mut one = clean.clone(); + one[1] = "beef".to_string(); + assert_eq!( + compare(&one, &goldens, "cpu"), + (1, Some(1)), + "one wrong frame must be reported once, at its DISPLAY index" + ); + + let mut two = one.clone(); + two[0] = "dead".to_string(); + assert_eq!( + compare(&two, &goldens, "cpu"), + (2, Some(0)), + "the first divergence must be the FIRST one, not the last seen" + ); + } + + /// [`localise`] separates the plane, the region and the magnitude — the three things + /// a hash cannot say and the three that located the last two defects. + #[test] + fn a_divergence_names_the_plane_the_box_and_the_magnitude() { + let (w, h) = (320u32, 240u32); + let clean = vec![0x40u8; (w * h + w * h / 2) as usize]; + + // One luma block, chroma clean — the NVIDIA signature. + let mut one_block = clean.clone(); + for y in 24..48u32 { + for x in 16..32u32 { + one_block[(y * w + x) as usize] = 0x48; + } + } + let d = localise(&one_block, &clean, (w, h), pf_vaadec::VA_FOURCC_NV12); + assert_eq!(d.luma_samples, 16 * 24); + assert_eq!(d.chroma_samples, 0); + assert_eq!(d.luma_box, Some((16, 24, 31, 47))); + assert_eq!(d.max_delta, 8); + assert!(format!("{d}").contains("chroma CLEAN")); + assert!(format!("{d}").contains("16x24")); + + // Chroma too, and badly — the Intel signature. + let structural = vec![0xffu8; clean.len()]; + let d = localise(&structural, &clean, (w, h), pf_vaadec::VA_FOURCC_NV12); + assert_eq!(d.luma_samples, (w * h) as usize); + assert_eq!(d.chroma_samples, (w * h / 2) as usize); + assert_eq!(d.max_delta, 0xff - 0x40); + assert!(!format!("{d}").contains("chroma CLEAN")); + + assert_eq!( + localise(&clean, &clean, (w, h), pf_vaadec::VA_FOURCC_NV12).luma_samples, + 0 + ); + assert_eq!( + format!( + "{}", + localise(&clean, &clean, (w, h), pf_vaadec::VA_FOURCC_NV12) + ), + "identical" + ); + } + + /// Ten-bit samples read as LSB-aligned are NAMED as a format problem rather than + /// reported as a decode divergence. + /// + /// The trap the Main 10 golden's header warns about, and the one thing about that + /// leg a reader would otherwise have to re-derive from 50 wrong hashes: P010 puts + /// the ten bits in the HIGH end of each 16-bit word, so a driver handing back + /// `yuv420p10le` produces a buffer of exactly the right LENGTH and entirely the + /// wrong content. + #[test] + fn lsb_aligned_ten_bit_samples_are_called_out_as_a_format_problem() { + let (w, h) = (16u32, 16u32); + let samples = (w * h + w * h / 2) as usize; + // MSB-aligned: 0x0200 is 8 << 6, and its low six bits are clear. + let msb: Vec = (0..samples).flat_map(|_| 0x0200u16.to_le_bytes()).collect(); + // LSB-aligned: the same ten-bit value, 8, unshifted. + let lsb: Vec = (0..samples).flat_map(|_| 0x0008u16.to_le_bytes()).collect(); + + let d = localise(&lsb, &msb, (w, h), pf_vaadec::VA_FOURCC_P010); + assert_eq!(d.low_bits_set, samples, "every sample carries low bits"); + assert!( + format!("{d}").contains("low six bits"), + "the report must point at the FORMAT: {d}" + ); + + let d = localise(&msb, &msb, (w, h), pf_vaadec::VA_FOURCC_P010); + assert_eq!(d.low_bits_set, 0); + assert_eq!(format!("{d}"), "identical"); + } } diff --git a/crates/pf-dxvadec/src/descriptors.rs b/crates/pf-dxvadec/src/descriptors.rs index 7d5ed39e..41d2eab4 100644 --- a/crates/pf-dxvadec/src/descriptors.rs +++ b/crates/pf-dxvadec/src/descriptors.rs @@ -304,6 +304,7 @@ mod tests { setup_slot: 0, setup_id: 1, setup_is_reference: true, + release_after_decode: Vec::new(), refs: Vec::::new(), mb_count, } diff --git a/crates/pf-dxvadec/src/pic.rs b/crates/pf-dxvadec/src/pic.rs index 6546c0cd..06d650b9 100644 --- a/crates/pf-dxvadec/src/pic.rs +++ b/crates/pf-dxvadec/src/pic.rs @@ -133,6 +133,60 @@ pub struct DecodePlanDxva { /// surface exists for the decode itself plus any remaining DPB residency, /// and may already have been released by this very AU's `removed`. pub setup_is_reference: bool, + /// Surfaces this access unit's own end-of-picture bookkeeping retires while the + /// submission still NAMES them. Release them once the decode op is issued — + /// never inside the conversion, and never dropped. + /// + /// # Why the conversion cannot release them + /// + /// [`SlotMap::assign`] takes the LOWEST FREE slot. Release a picture here and the + /// setup assignment two lines later hands its surface straight back, so the + /// submission says `CurrPic = N` and `RefFrameList[k] = N` in one breath: the + /// picture decodes into a surface it predicts from. That is the AV1 defect of + /// 2026-08-07 ([`crate::pic_av1::DecodePlanDxvaAv1::release_after_decode`]) on this + /// codec, and on H.264 it is not exotic at all. + /// + /// [`AuPlan::dpb_refs`] — which `RefFrameList` is built from — is snapshotted in + /// `H264Planner::begin_picture`, BEFORE `finish_picture` runs 8.2.5's marking and + /// C.4.5.3's bump. So a picture the sliding window unmarks and the bump then evicts + /// lands in both `dpb_refs` and `dpb.removed` for the same AU. It needs the two to + /// coincide, which needs the evicted picture to be already OUTPUT — and that is + /// precisely low-delay H.264: `max_num_reorder_frames = 0`, a picture output the + /// moment it decodes. + /// + /// **Measured 2026-08-07, and it is the ordinary case, not a corner.** Every stream + /// a punktfunk host emits does it on 297 of 300 access units — 720p, 1080p and + /// 2160p alike, on both this rung and [`pf_vkdecode::pic::DecodePlanVk`]'s. NVENC + /// writes `max_num_ref_frames = 3` AND `max_dec_frame_buffering = 3`: the DPB is + /// exactly as deep as the reference count, so the window unmarks the oldest + /// reference in the very AU whose bump evicts it. The aliased picture is + /// `ref_idx 2` of a three-entry `num_ref_idx_l0_active` list — addressable by any + /// macroblock, not a spare the hardware could ignore. + /// + /// `test-25fps.h264` measures ZERO and that is why this survived to here: it is + /// level 1.3 with no VUI `bitstream_restriction`, so its DPB is the level-derived 7 + /// against 2 reference frames, and it REORDERS, which keeps an unmarked picture + /// alive past the AU that unmarked it. `data/lowdelay-640x480.h264` is vendored to + /// close exactly that gap. + /// + /// # Why the caller can release them safely + /// + /// The surfaces must outlive the CONVERSION, not the decode. One AU is planned, + /// converted and submitted before the next is planned, so once the decode op is + /// issued nothing can be assigned them before the next conversion — the same + /// argument the AV1 rung's deferral rests on. + /// + /// # Deferring the whole `removed` list rather than a filtered part + /// + /// Some removals are pictures no `RefFrameList` entry names (a non-reference + /// picture bumped long after it was unmarked), and those could be released here. + /// They are not, for three reasons: `refs` is built from the SLICE LISTS as well as + /// the snapshot, so a filter on `dpb_refs` would still miss a concealment + /// substitute the lists name; deferring costs nothing, because + /// [`SlotMap::new`]'s spare slot means `assign` always has a free slot while every + /// removal is still held (the DPB never exceeds `max_dpb_frames`, and the map holds + /// `max_dpb_frames + 1`); and one unconditional rule is a thing a reader can check. + pub release_after_decode: Vec, /// The marked DPB, resolved to surfaces — the AU's own references first, then /// every other marked picture (module docs). Laid out in exactly this order in /// `pic_params.RefFrameList`. @@ -268,7 +322,10 @@ impl From for PlanToDxvaError { /// 2. references resolve against the PRE-removal state (read-only) — this AU's /// own end-of-picture marking can evict a picture its slices legitimately /// reference; -/// 3. `removed` is applied, then the setup slot is assigned last. +/// 3. the setup slot is assigned last, and `removed` is NOT applied at all: it +/// leaves as [`DecodePlanDxva::release_after_decode`] for the caller to apply +/// once the decode op is issued. Applying it here would give the assignment +/// back a surface this submission still names — see that field's docs. pub fn plan_to_dxva( plan: &AuPlan, slots: &mut SlotMap, @@ -480,24 +537,29 @@ pub fn plan_to_dxva( let slice_ranges: Vec> = plan.slices.iter().map(|s| s.data.clone()).collect(); - // Mutations LAST, after every fallible step above (fn docs). Removals first — - // they were real regardless of this AU's fate — then the setup assignment. + // Mutations LAST, after every fallible step above (fn docs). + // + // The removals are NOT applied here — they are handed back as + // `release_after_decode` for the caller to apply once the decode op is issued, + // because releasing them now would return their surfaces to the setup + // assignment below and alias `CurrPic` with a `RefFrameList` entry. The field's + // docs carry the measurement; this is the ordinary case on every stream a + // punktfunk host emits. // // The AU's own picture can itself appear in `removed`: a non-reference // picture with no free frame buffer bypasses the DPB and is stored-and- // evicted within one plan. Its surface must still exist for the decode - // itself, so it is assigned here and released right after. + // itself, so it is assigned here and released right after — the one removal + // that cannot be deferred, since deferring it would hand the caller the + // surface being decoded into. let setup_evicted = plan.dpb.removed.contains(&setup_id); - for &id in &plan.dpb.removed { - if id == setup_id { - continue; - } - if !slots.release(id) { - // Tolerated but never silent: reachable only when the caller skipped - // feeding an AU's plan through this map. - trace!(id, "DpbUpdate removed an id this SlotMap never assigned"); - } - } + let release_after_decode: Vec = plan + .dpb + .removed + .iter() + .copied() + .filter(|id| *id != setup_id) + .collect(); let setup_slot = slots.assign(setup_id)?; if setup_evicted { slots.release(setup_id); @@ -523,6 +585,7 @@ pub fn plan_to_dxva( setup_slot, setup_id, setup_is_reference: pic.is_reference, + release_after_decode, refs, mb_count: width_mbs * height_mbs, }) @@ -570,6 +633,17 @@ mod tests { "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" ); + /// **Our own host's output**, and the only stream in this repository that reaches + /// the shape `release_after_decode` exists for: low-delay IPPP, 120 pictures, + /// `max_num_reorder_frames = 0`, and a DPB exactly as deep as its reference count. + /// + /// Vendored beside the goldens the GPU legs decode it against (that file's header + /// carries the `punktfunk-host spike` command and the ffmpeg cross-check), because + /// three crates need it: this one for the CPU proof, `pf-vkdecode`'s `gpu_parity` + /// and `pf-client-core`'s `video_d3d11_native::parity` for the hardware one. + const LOWDELAY_640X480: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264"); + /// Test-only AU splitter, the same shape pf-vkdecode's `pic` tests use /// (which in turn mirrors pf-bitstream's `#[cfg(test)]`-private helper): a /// new AU starts at a non-slice NALU following a slice, or at a slice whose @@ -602,10 +676,22 @@ mod tests { /// Plan the vendored stream and convert every AU, returning the plans paired /// with their conversions. fn convert_stream() -> Vec<(AuPlan, DecodePlanDxva)> { + convert(TEST_25FPS) + } + + /// The same over [`LOWDELAY_640X480`]. + fn convert_low_delay() -> Vec<(AuPlan, DecodePlanDxva)> { + convert(LOWDELAY_640X480) + } + + /// Plan and convert a whole stream the way a caller does — including applying + /// `release_after_decode` once the (notional) decode op is issued, which is what + /// keeps the ledger from filling up over 120 access units. + fn convert(stream: &[u8]) -> Vec<(AuPlan, DecodePlanDxva)> { let mut planner = H264Planner::new(); let mut slots: Option = None; let mut out = Vec::new(); - for (i, au) in split_into_aus(TEST_25FPS).into_iter().enumerate() { + for (i, au) in split_into_aus(stream).into_iter().enumerate() { let Ok(plan) = planner.plan_au(au) else { continue; }; @@ -614,6 +700,9 @@ mod tests { *map = SlotMap::new(plan.picture.max_dpb_frames); } let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion"); + for &id in &dxva.release_after_decode { + assert!(map.release(id), "AU {i}: deferred id {id} held no slot"); + } out.push((plan, dxva)); } out @@ -881,6 +970,156 @@ mod tests { assert_eq!(converted.len(), 250); } + /// The hazard the vendored vector CANNOT see, on a stream that can. + /// + /// `removed ∩ dpb_refs` is the aliasing precondition: a picture this AU's own + /// end-of-picture bookkeeping retires while `RefFrameList` still names it. Release + /// it inside the conversion and [`SlotMap::assign`] hands its surface straight back + /// to `CurrPic`, so the picture decodes into one it predicts from. + /// + /// On `test-25fps.h264` the intersection is **zero**, and for two independent + /// reasons that both happen to be properties of that vector rather than of H.264: + /// it is level 1.3 with no VUI `bitstream_restriction`, so `dpb_limit` falls back to + /// A.3.1's level ceiling and gives a 7-frame DPB against `max_num_ref_frames = 2` + /// (the sliding window unmarks two AUs before the bump can evict); and it REORDERS, + /// which keeps an unmarked picture alive for output past the AU that unmarked it. + /// That zero is what let the eager release survive two milestones. + /// + /// On `lowdelay-640x480.h264` — OUR host's output, vendored for exactly this — it is + /// **117 of 120 access units**, measured the same way at 720p, 1080p and 2160p. The + /// difference is the encoder, not the resolution: NVENC writes + /// `max_num_ref_frames = 3` AND `max_dec_frame_buffering = 3`, a DPB exactly as deep + /// as the reference count, so 8.2.5's window unmarks the oldest reference in the very + /// AU whose C.4.5.3 bump evicts it — and `max_num_reorder_frames = 0` means it has + /// already been output, which is what makes it evictable at all. + /// + /// So this is not a tripwire any more: it pins BOTH numbers, and the second one is + /// what makes `release_after_decode` a fixed defect rather than a precaution. + /// + /// HEVC needs no such test: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps` + /// has updated the DPB, so an RPS-dropped picture is structurally never in the + /// snapshot `RefPicList` is built from — and a low-delay HEVC stream from the same + /// host measures 0 of 300, which is the argument confirmed rather than assumed. + #[test] + fn the_low_delay_stream_removes_pictures_its_own_reference_list_names_and_the_vector_never_does( + ) { + fn intersections(stream: &[u8]) -> (usize, usize, usize) { + let mut planner = H264Planner::new(); + let (mut both, mut with_removals, mut planned) = (0usize, 0usize, 0usize); + for au in split_into_aus(stream) { + let Ok(plan) = planner.plan_au(au) else { + continue; + }; + planned += 1; + if !plan.dpb.removed.is_empty() { + with_removals += 1; + } + both += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); + } + (planned, with_removals, both) + } + + let (planned, with_removals, both) = intersections(TEST_25FPS); + assert_eq!(planned, 250); + assert!( + with_removals > 0, + "no access unit of the vendored vector removed anything, so the zero below \ + would be empty for a reason that has nothing to do with the hazard" + ); + assert_eq!( + both, 0, + "the vendored vector is supposed to be BLIND to this shape — a non-zero \ + here means the reordering/DPB-depth reasoning above is wrong, and the \ + low-delay numbers below need re-deriving before they mean anything" + ); + + let (planned, with_removals, both) = intersections(LOWDELAY_640X480); + assert_eq!(planned, 120); + assert_eq!(with_removals, 117); + assert_eq!( + both, 117, + "the low-delay stream must still exercise the aliasing precondition on \ + nearly every access unit — if this ever drops to zero the deferral below \ + is no longer being TESTED by anything, whatever else still passes" + ); + } + + /// The fix itself: no submission names its decode surface as a reference. + /// + /// [`the_setup_surface_is_the_current_picture_entry_and_is_never_also_a_reference_entry`] + /// asserts this over the vendored vector, where it held even before + /// `release_after_decode` existed. This is the same invariant over the stream that + /// BREAKS it — 117 of 120 access units before the deferral, every one of them + /// decoding into a surface it predicts from. + #[test] + fn the_low_delay_stream_never_aliases_its_decode_surface_with_a_reference() { + let converted = convert_low_delay(); + assert_eq!(converted.len(), 120); + let mut deferred_total = 0usize; + for (i, (_, dxva)) in converted.iter().enumerate() { + assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot); + deferred_total += dxva.release_after_decode.len(); + for r in &dxva.refs { + assert_ne!( + r.slot, dxva.setup_slot, + "AU {i}: reference picture {} shares surface {} with the decode \ + target — the deferral is not holding", + r.id, r.slot + ); + } + } + assert_eq!( + deferred_total, 117, + "every access unit that removes a picture must defer it; a zero here with \ + the assertions above still passing would mean the stream stopped \ + exercising the shape" + ); + } + + /// The deferral costs no slot the map does not have. + /// + /// Holding every removal through the setup assignment is only free because + /// [`SlotMap::new`] allocates `max_dpb_frames + 1` and the DPB never exceeds + /// `max_dpb_frames` — so a free slot always exists even with the whole `removed` + /// list still held. Measured rather than argued: the deepest the ledger ever gets + /// on the stream that defers on 117 of 120 access units. + #[test] + fn deferring_every_removal_still_fits_the_ledger() { + let mut planner = H264Planner::new(); + let mut slots: Option = None; + let mut peak = 0usize; + let mut capacity = 0usize; + for (i, au) in split_into_aus(LOWDELAY_640X480).into_iter().enumerate() { + let Ok(plan) = planner.plan_au(au) else { + continue; + }; + let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + if map.capacity() != plan.picture.max_dpb_frames + 1 { + *map = SlotMap::new(plan.picture.max_dpb_frames); + } + let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion"); + // The peak is measured BEFORE the deferred releases are applied: that is + // the moment the map is fullest, and the moment `assign` had to find a + // free slot in. + peak = peak.max(map.held().count()); + capacity = map.capacity(); + for &id in &dxva.release_after_decode { + assert!(map.release(id), "AU {i}: deferred id {id} held no slot"); + } + } + assert_eq!(capacity, 4, "max_dec_frame_buffering 3 + the spare slot"); + assert_eq!( + peak, 4, + "the deferral is expected to USE the spare slot — a peak of 3 would mean \ + the removals are being released early after all" + ); + } + #[test] fn the_setup_surface_is_the_current_picture_entry_and_is_never_also_a_reference_entry() { for (_, dxva) in convert_stream() { @@ -1101,27 +1340,47 @@ mod tests { assert_eq!(&bytes[10..14], &40u32.to_le_bytes()); } + /// The whole-stream churn check: no two live pictures may share a surface index, + /// which for DXVA is the difference between a decode and a corrupted reference. + /// + /// Run over BOTH streams, because they stress opposite halves of the ledger: the + /// vendored vector has a DPB (7) far deeper than its reference count (2) and so + /// never has to reuse a surface promptly, while the low-delay stream's DPB is + /// exactly its reference count and cycles all four slots every four pictures. + /// + /// The loop applies `release_after_decode` because the CALLER does; a loop that + /// drops it holds a surface per access unit and dies of `SlotError::Full` — which + /// is what this test did the moment the deferral landed, and is the cheapest + /// possible demonstration that the deferral is real rather than decorative. #[test] fn a_slot_is_reused_only_after_its_picture_leaves_the_dpb() { - // The whole-stream churn check: no two live pictures may share a surface - // index, which for DXVA is the difference between a decode and a - // corrupted reference. - let mut planner = H264Planner::new(); - let mut slots: Option = None; - let mut live: Vec<(PicId, u8)> = Vec::new(); - for (i, au) in split_into_aus(TEST_25FPS).into_iter().enumerate() { - let Ok(plan) = planner.plan_au(au) else { - continue; - }; - let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); - let removed = plan.dpb.removed.clone(); - let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion"); - live.retain(|&(id, _)| !removed.contains(&id)); - assert!( - live.iter().all(|&(_, slot)| slot != dxva.setup_slot), - "AU {i} decodes into a surface a live picture still holds" - ); - live.push((dxva.setup_id, dxva.setup_slot)); + for (label, stream) in [("vendored", TEST_25FPS), ("low-delay", LOWDELAY_640X480)] { + let mut planner = H264Planner::new(); + let mut slots: Option = None; + let mut live: Vec<(PicId, u8)> = Vec::new(); + for (i, au) in split_into_aus(stream).into_iter().enumerate() { + let Ok(plan) = planner.plan_au(au) else { + continue; + }; + let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + let removed = plan.dpb.removed.clone(); + let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion"); + // The check runs BEFORE the deferred releases: the aliasing this + // guards against is a property of the SUBMISSION, and at submission + // time every removed picture is still live by construction. + assert!( + live.iter().all(|&(_, slot)| slot != dxva.setup_slot), + "{label} AU {i} decodes into a surface a live picture still holds" + ); + for &id in &dxva.release_after_decode { + assert!( + map.release(id), + "{label} AU {i}: deferred id {id} held no slot" + ); + } + live.retain(|&(id, _)| !removed.contains(&id)); + live.push((dxva.setup_id, dxva.setup_slot)); + } } } } diff --git a/crates/pf-dxvadec/src/pic_av1.rs b/crates/pf-dxvadec/src/pic_av1.rs index d9614f48..4446ecc8 100644 --- a/crates/pf-dxvadec/src/pic_av1.rs +++ b/crates/pf-dxvadec/src/pic_av1.rs @@ -122,6 +122,47 @@ pub struct DecodePlanDxvaAv1 { pub bitstream: Av1Bitstream, pub setup_slot: u8, pub setup_id: PicId, + /// Pictures this frame's own `refresh_frame_flags` displaces from the store + /// while THIS submission still NAMES them — their surfaces may not be recycled + /// until the decode op has been issued, and the caller owes exactly that. + /// + /// AV1 applies `refresh_frame_flags` AFTER the frame is decoded (7.20), so + /// `ref_frame_idx` resolves against the store as it stood BEFORE this frame and + /// a frame that reads a slot it then overwrites is the ORDINARY case, not an + /// exotic one: **268 of the vendored vector's 274 frames** do it, first at frame + /// 6. Releasing such a picture inside this conversion — which is what the H.264 + /// and H.265 siblings do with their whole `removed` list, and what this one did + /// until the parity harness caught it — hands its surface straight back to + /// [`Self::setup_slot`], because [`SlotMap::assign`] takes the lowest free slot + /// and the lowest free slot is the one just vacated. The submission then says + /// `CurrPicTextureIndex = N` and `RefFrameMapTextureIndex[k] = N` in the same + /// breath: decode into the surface you are predicting from. + /// + /// Neither vendored H.264 nor H.265 vector ever produces that shape (measured: + /// zero on the 250-AU clips), which is why the eager release survived two + /// codecs believed hardware-proven and opened on the first AV1 frame past the key + /// frame's neighbourhood. + /// + /// ⚠ That zero turned out to be a fact about the VECTORS. H.264 has the identical + /// defect on any low-delay stream — 117 of 120 access units of our own host's + /// output, wrong pixels on three GPUs — and now carries the identical deferral + /// ([`crate::pic::DecodePlanDxva::release_after_decode`], which records the + /// measurement). H.265 is the only one of the three that is genuinely safe, and + /// structurally: its planner snapshots `dpb_refs` after `decode_rps`. The Vulkan rung + /// carries the same contract for the same reason + /// (`pf_vkdecode::pic_av1::DecodePlanVkAv1::release_after_decode`), and this + /// rung's constraint is the STRICTER of the two: Vulkan binds only the references + /// the frame names, while `RefFrameMapTextureIndex` declares the whole store, so + /// every picture the store still names has to survive — not just the seven the + /// frame reads. + /// + /// This is exactly `dpb.removed` less the picture being stored, and that is not a + /// coincidence to be tidied into a filter: the planner snapshots `dpb_refs` before + /// any mutation, so every removal is by construction a picture the store named + /// (see the conversion's own comment). Applying the list completes the plan's + /// bookkeeping and never invents a removal. A caller that drops it holds a surface + /// on nearly every frame and runs the ledger dry within ten. + pub release_after_decode: Vec, } /// Why a plan cannot be expressed as DXVA AV1 buffers. @@ -629,12 +670,32 @@ pub fn plan_to_dxva_av1( } // --- mutations, after every fallible step ----------------------------- - for &id in &plan.dpb.removed { - if id == setup_id { - continue; - } - let _ = slots.release(id); - } + // ⚠ EVERY removed picture is held back, and nothing is released here at all. + // + // A picture this submission NAMES may be displaced by this same frame's refresh; + // its surface is still in `ref_frame_map` above, so releasing it would hand that + // very surface to `setup_slot` below and the frame would decode into a picture it + // predicts from. What makes the rule "every removal" rather than "the removals + // the store names" is a property of the PLANNER: `Av1Planner::plan_frame` + // snapshots `dpb_refs` before any mutation, and `refresh_slots` can only report a + // picture that was in `self.slots` at that moment, so `dpb.removed` is always a + // SUBSET of the store `ref_frame_map` was built from. Filtering on `dpb_refs` + // here would be a condition that is never false wearing the clothes of a + // decision; the subset relation is asserted in + // `the_decode_target_never_aliases_a_surface_the_submission_names` instead, where + // a planner change that broke it fails loudly. + // + // `setup_id` is excluded as a safety property rather than as a live case: a + // picture this frame stores cannot also be one its own refresh displaced, because + // `refresh_slots` retains out any displaced id still held anywhere. Releasing it + // would return the surface being decoded into. + let release_after_decode: Vec = plan + .dpb + .removed + .iter() + .copied() + .filter(|id| *id != setup_id) + .collect(); let setup_slot = match slots.slot_of(setup_id) { Some(existing) => existing, None => slots.assign(setup_id)?, @@ -642,6 +703,20 @@ pub fn plan_to_dxva_av1( let color = &seq.color_config; let mut pic_params = PicParamsAv1::zeroed(); + // ⚠ UPSCALED width, where libavcodec sends the CODED one — a divergence that is + // inert on every stream that exists here and is written down rather than + // "fixed" because nothing can measure it. + // + // `dxva2_av1.c` sends `avctx->width`, and `update_context_with_frame_header` + // sets that from `frame_width_minus_1 + 1` — FrameWidth, the pre-superres coded + // width. The same goes for `frame_refs[i].width`, which libav reads off the + // reference's `AVFrame`. With superres OFF the two are equal by definition + // (7.20: `UpscaledWidth = FrameWidth` when `use_superres` is 0), which is every + // frame of both vendored vectors and every frame a punktfunk host emits — no + // encoder in this program codes superres. So the 250/250 parity result on two + // vendors says nothing either way about which is right, and changing it would + // be an unmeasured change to a rung that is finally proven. Revisit with a + // superres vector and a driver-by-driver measurement, not by reading. pic_params.width = h.upscaled_width; pic_params.height = h.frame_height; pic_params.max_width = u32::from(seq.max_frame_width_minus_1) + 1; @@ -758,6 +833,7 @@ pub fn plan_to_dxva_av1( bitstream, setup_slot, setup_id, + release_after_decode, }) } @@ -781,6 +857,157 @@ mod tests { "../../pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1" ); + /// **Our own host's AV1 at 4K**, vendored beside the goldens the GPU legs decode it + /// against (`lowdelay-3840x2160-av1.nv12.sha256` carries the `punktfunk-host spike` + /// command and the ffmpeg cross-check). + /// + /// It is here for ONE property the vendored vector cannot supply: **two tiles**. + /// Every frame of `test-25fps.ivf.av1` is `tile_cols = tile_rows = 1`, so + /// [`the_tile_sizes_are_superblock_counts_not_the_coded_minus_one`] can only ever + /// read index 0 of the tile arrays and assert the rest are zero. This stream is + /// `tile_cols = 1, tile_rows = 2` on all 60 frames, with both tiles in a single + /// Tile Group OBU — the 4K split-encode shape the host ships. + const LOWDELAY_3840X2160_AV1: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1"); + + /// Convert one plan the way the RUNG must: the conversion, then the releases it + /// defers past the decode op ([`DecodePlanDxvaAv1::release_after_decode`]). + /// + /// Not a convenience — it is the caller's half of the contract, and the same + /// helper the Vulkan rung's tests carry for the same reason. A loop that + /// converts without it holds a surface on 268 of this vector's 274 frames and + /// runs the nine-slot ledger dry inside ten. + fn convert(au: &[u8], plan: &AuPlan, slots: &mut SlotMap) -> DecodePlanDxvaAv1 { + let dx = plan_to_dxva_av1(au, plan, slots).expect("the clean vector converts"); + for &id in &dx.release_after_decode { + assert!( + slots.release(id), + "a deferred release named picture {id}, which holds no surface" + ); + } + dx + } + + /// The decode target never shares a surface with a picture the submission names. + /// + /// The defect this pins is the one the Windows parity harness caught and nothing + /// on the CPU could see. AV1 applies `refresh_frame_flags` AFTER the frame is + /// decoded (7.20), so a frame that reads a slot it then overwrites is ordinary — + /// **268 of this vector's 274 frames**, first at frame 6 — and releasing the + /// displaced picture inside the conversion handed its surface straight to + /// `setup_slot`, because [`SlotMap::assign`] takes the lowest free slot and the + /// lowest free slot is the one just vacated. The submission then said + /// `CurrPicTextureIndex = N` and `RefFrameMapTextureIndex[k] = N` at once. + /// + /// Measured on hardware before the fix: Intel Arc decoded 245 of 250 delivered + /// frames wrong (47% of luma at the first bad frame, max |delta| 242, chroma + /// wrong too — a frame predicted from the wrong picture), and the only late + /// frame it got right was the one intra frame, which names no reference and so + /// could not alias. NVIDIA tolerated it. + /// + /// The assertion is against the WHOLE STORE, not just the seven names this frame + /// reads: `RefFrameMapTextureIndex` declares every occupied slot, so a driver is + /// entitled to consult one the frame never names. + #[test] + fn the_decode_target_never_aliases_a_surface_the_submission_names() { + let mut planner = Av1Planner::new(); + let mut slots = SlotMap::new(NUM_REF_SLOTS); + let (mut frames, mut deferring, mut deferred) = (0u32, 0u32, 0u32); + let mut peak = 0usize; + + for packet in IvfIterator::new(AV1_25FPS) { + for plan in planner.plan_au(packet).expect("the clean vector plans") { + if plan.dpb.stored.is_none() { + continue; + } + // Deliberately NOT `convert` — this test applies the deferred + // releases itself, after checking each one. + let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("the vector converts"); + frames += 1; + peak = peak.max(slots.active()); + + // `#[repr(packed)]` — copy the fields out before reading them. + let curr = dx.pic_params.curr_pic_texture_index; + let store = dx.pic_params.ref_frame_map_texture_index; + assert!( + store.iter().all(|surface| *surface != curr), + "frame {frames}: surface {curr} is both CurrPicTextureIndex and a \ + RefFrameMapTextureIndex entry — the frame decodes into a picture \ + it predicts from" + ); + // ⚠ THE PROPERTY THE DEFERRAL RESTS ON, asserted about the PLANNER + // rather than about the conversion's own output. + // + // The conversion holds back every removal, which is only safe-and- + // sufficient because `Av1Planner` snapshots `dpb_refs` before any + // mutation and `refresh_slots` can only report a picture that was in + // it — so `dpb.removed` is a subset of the store `ref_frame_map` was + // built from. Asserting instead that each DEFERRED id is in + // `dpb_refs` would be vacuous: the deferred list is filtered out of + // `removed`, so that check compares an expression with itself. This + // one can fail, and if a planner change ever makes it fail the + // conversion is releasing a surface the submission points at. + for &id in &plan.dpb.removed { + assert!( + plan.dpb_refs.iter().any(|r| r.id == id), + "frame {frames}: the planner removed picture {id}, which the \ + pre-decode store never held — `ref_frame_map` is built from \ + that store, so a removal outside it is a picture this \ + conversion could release without aliasing, and the blanket \ + deferral above stops being justified" + ); + } + // Every deferred id is one this plan really removed, and it still + // holds the surface the caller is being asked to give back. + for &id in &dx.release_after_decode { + assert!( + plan.dpb.removed.contains(&id), + "frame {frames}: deferred picture {id} is not in this plan's \ + removed list" + ); + assert_ne!( + id, dx.setup_id, + "frame {frames}: the picture being decoded must never be \ + deferred — releasing it returns the surface being written" + ); + assert!( + slots.slot_of(id).is_some(), + "frame {frames}: a deferred picture must still hold its surface" + ); + } + if !dx.release_after_decode.is_empty() { + deferring += 1; + deferred += dx.release_after_decode.len() as u32; + } + for &id in &dx.release_after_decode { + assert!(slots.release(id)); + } + } + } + + assert_eq!(frames, 274); + assert_eq!( + deferring, 268, + "268 of 274 frames of this vector displace a picture their own submission \ + names; at zero this test compares an empty list against itself and the \ + deferral could be deleted without a single assertion noticing" + ); + assert_eq!(deferred, 268, "one displaced picture per frame here"); + // The nine-slot ledger is `NUM_REF_SLOTS + 1`, and holding a displaced + // picture one frame longer is exactly what that spare is for — the pool is + // allocated `SlotMap::capacity()` surfaces (`pf_dxvadec::pool_size`), so a + // peak above it would be a submission naming a surface that does not exist. + assert!( + peak <= slots.capacity(), + "peak {peak} surfaces held exceeds the {} the pool allocates", + slots.capacity() + ); + eprintln!( + "frames {frames} · deferring {deferring} · peak surfaces held {peak}/{}", + slots.capacity() + ); + } + /// The whole vector, converted **and packed** — the closest a CPU gate gets to /// the hardware leg, and the test that would have caught the defect this /// module shipped with. @@ -815,7 +1042,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; // Poison the mapping so a record can only be "right" by pointing // at bytes this pack actually wrote. @@ -949,8 +1176,7 @@ mod tests { index_by_surface_would_differ += 1; } } - let dx = - plan_to_dxva_av1(packet, &plan, &mut slots).expect("the clean vector converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; // Tile records must describe TILE PAYLOAD ranges inside the access @@ -1153,7 +1379,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; let lf = &plan.header.loop_filter_params; // `#[repr(packed)]` — copy the block out before reading its fields. @@ -1244,7 +1470,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; let raw = &plan.header.cdef_params; // `#[repr(packed)]` — copy the arrays out before indexing them. @@ -1335,7 +1561,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; let t = &plan.header.tile_info; // `#[repr(packed)]` — copy the block out before reading its arrays. @@ -1375,6 +1601,123 @@ mod tests { assert_eq!(frames, 274); } + /// The same tile arrays with a SECOND tile in them — the case the vendored vector + /// cannot reach and this conversion had therefore never been run against. + /// + /// [`the_tile_sizes_are_superblock_counts_not_the_coded_minus_one`] asserts index 0 + /// is right and `1..` are zero, which is everything a one-tile vector can say. Both + /// halves of that are shapes a multi-tile bug would satisfy: a conversion that + /// wrote only tile 0 and left the rest zero would pass it on every frame of the + /// vector and hand the driver a frame with half its height missing here. + /// + /// So this pins the row arrays with both entries live, that the second entry is the + /// SECOND tile's size rather than a repeat of the first, and that everything past + /// the grid is still zero. `tile_rows = 2` with `height_in_sbs_minus_1 = [16, 16]` + /// against a 2160-line frame is 17 + 17 = 34 superblocks of 64, i.e. 2176 lines — + /// the padded height, which is the arithmetic the one-tile test's `div_ceil` also + /// checks but cannot check twice. + #[test] + fn a_two_tile_frame_fills_both_row_entries_and_leaves_the_rest_zero() { + let mut planner = Av1Planner::new(); + let mut slots = SlotMap::new(NUM_REF_SLOTS); + let mut frames = 0u32; + + for packet in IvfIterator::new(LOWDELAY_3840X2160_AV1) { + for plan in planner + .plan_au(packet) + .expect("the low-delay 4K stream plans") + { + if plan.dpb.stored.is_none() { + continue; + } + let dx = convert(packet, &plan, &mut slots); + frames += 1; + let t = &plan.header.tile_info; + // `#[repr(packed)]` — copy the block out before reading its arrays. + let tiles = dx.pic_params.tiles; + let (widths, heights) = (tiles.widths, tiles.heights); + assert_eq!( + (tiles.cols, tiles.rows), + (1, 2), + "frame {frames}: this stream is vendored FOR its second tile row. \ + One tile here means it was regenerated below 4K (1440p and down \ + measured single-tile) and this test has quietly become a duplicate \ + of the vendored vector's" + ); + let sb = if plan.sequence.use_128x128_superblock { + 128 + } else { + 64 + }; + assert_eq!( + (widths[0], 0u16), + (plan.header.frame_width.div_ceil(sb) as u16, 0u16), + "frame {frames}: the single tile COLUMN spans the whole width" + ); + // Both row entries, each the coded value plus one — and read + // independently, so a conversion that broadcast entry 0 across the + // array would still have to get entry 1's own coded value right. + assert_eq!( + (heights[0], heights[1]), + ( + t.height_in_sbs_minus_1[0] as u16 + 1, + t.height_in_sbs_minus_1[1] as u16 + 1 + ), + "frame {frames}: each tile row's height is its OWN \ + `height_in_sbs_minus_1 + 1`" + ); + assert_eq!( + u32::from(heights[0]) + u32::from(heights[1]), + plan.header.frame_height.div_ceil(sb), + "frame {frames}: the two tile rows must tile the frame exactly — a \ + short second row is a frame with missing lines, which is precisely \ + the shape the host once shipped over the wire" + ); + // Past the grid the arrays stay zero: a driver reading `rows` entries + // never sees them, and a phantom entry is a tile the frame has not. + assert!(widths[1..].iter().all(|w| *w == 0)); + assert!(heights[2..].iter().all(|h| *h == 0)); + + // TWO tile RECORDS from ONE tile group, with distinct rows and + // non-empty spans. `tile_cols * tile_rows` records is libavcodec's own + // count, and this stream is the only one here where it exceeds the + // number of tile GROUPS — so a conversion that emitted one record per + // group (the coarser shape the module docs warn against) is + // indistinguishable from a correct one on the vendored vector and + // fails here. + assert_eq!( + plan.tiles.len(), + 1, + "frame {frames}: both tiles arrive in one Tile Group OBU" + ); + assert_eq!( + dx.tiles.len(), + 2, + "frame {frames}: one record per TILE, not per tile group" + ); + assert_eq!( + (dx.tiles[0].row, dx.tiles[0].column), + (0, 0), + "frame {frames}: tile 0 is row 0" + ); + assert_eq!( + (dx.tiles[1].row, dx.tiles[1].column), + (1, 0), + "frame {frames}: tile 1 is the SECOND ROW of a single column — a \ + (0, 1) here means rows and columns are transposed, which one \ + square tile grid could never show" + ); + assert!( + dx.tiles.iter().all(|r| r.data_size > 0), + "frame {frames}: every tile record must span real bytes; a \ + zero-length second record is the whole bottom half of the frame \ + missing" + ); + } + } + assert_eq!(frames, 60, "the low-delay 4K stream is 60 coded frames"); + } + /// Three fields whose correct value is a SENTINEL or a constant, on every frame /// of the vector — none of which any other assertion here would notice. /// @@ -1398,7 +1741,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts"); + let dx = convert(packet, &plan, &mut slots); frames += 1; let pp = &dx.pic_params; let status = pp.status_report_feedback_number; @@ -1478,7 +1821,7 @@ mod tests { if plan.dpb.stored.is_none() { continue; } - let dx = plan_to_dxva_av1(packet, plan, &mut slots).expect("converts"); + let dx = convert(packet, plan, &mut slots); decoded.push((dx.setup_id, dx.setup_slot)); } if decoded.len() > 1 { diff --git a/crates/pf-dxvadec/src/pic_h265.rs b/crates/pf-dxvadec/src/pic_h265.rs index dbb32293..7041c376 100644 --- a/crates/pf-dxvadec/src/pic_h265.rs +++ b/crates/pf-dxvadec/src/pic_h265.rs @@ -690,6 +690,18 @@ mod tests { "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/64x64-I-P-B-P.h265" ); + /// **Our own host's HEVC**, and the only stream in this repository that reaches + /// the DPB pressure HEVC's exemption is claimed against: low-delay IPPP, 120 + /// pictures of 640x480, `sps_max_num_reorder_pics = 0`, and a five-picture DPB + /// against the four pictures 8.3.2 keeps marked. + /// + /// Vendored beside the goldens the GPU legs decode it against (that file's header + /// carries the `punktfunk-host spike` command and the ffmpeg cross-check), the + /// same way `lowdelay-640x480.h264` is, and read from the same path by all three + /// crates that need it. + const LOWDELAY_640X480_H265: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h265"); + /// Test-only AU splitter, mirroring pf-vkdecode's (which mirrors /// pf-bitstream's `#[cfg(test)]`-private helper). fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { @@ -916,8 +928,36 @@ mod tests { } } + /// HEVC's freedom from the aliasing that cost AV1 and H.264 a deferral, and the + /// PLANNER property that grants it. + /// + /// Both other codecs release a removed picture's slot and then let + /// [`SlotMap::assign`] hand it straight back to the decode target, so `CurrPic` + /// and a `RefPicList` entry name one surface. This conversion still releases its + /// whole `removed` list inline, and is safe doing so for one reason: `H265Planner` + /// snapshots `dpb_refs` AFTER `decode_rps` has updated the DPB, so a picture this + /// AU's RPS dropped is never in the set `RefPicList` is built from, and nothing + /// later in the AU unmarks anything. `H264Planner` and `Av1Planner` both snapshot + /// BEFORE their marking, and both needed the deferral. + /// + /// The second assertion is that argument made falsifiable. The first is only the + /// consequence, and on this vector the consequence would hold even if the argument + /// stopped being true — the vendored H.264 vector taught that lesson expensively + /// (it measured zero aliasing for two milestones while every stream we ship + /// aliased on 99% of its frames). Moving `dpb_snapshot()` above `decode_rps` would + /// leave the first assertion passing and break the second on the first AU whose + /// RPS drops a picture, which on this vector is most of them. + /// + /// This test covers the VENDORED VECTOR only, which reorders and therefore cannot + /// reach the DPB pressure the exemption is really claimed against. The stream that + /// can is [`LOWDELAY_640X480_H265`], and it carries its own pair of tests below — + /// [`the_low_delay_stream_reaches_the_dpb_pressure_and_hevc_still_does_not_alias`] + /// and [`the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps`], + /// the second of which drives the alias through this very conversion. #[test] fn the_current_picture_is_named_by_curr_pic_and_never_aliases_a_reference() { + let mut aus_with_removals = 0usize; + let mut both = 0usize; for (plan, dxva) in convert_stream(TEST_25FPS) { assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot); assert!(!dxva.pic_params.CurrPic.associated()); @@ -928,7 +968,224 @@ mod tests { for r in &dxva.refs { assert_ne!(r.slot, dxva.setup_slot, "a reference aliases the target"); } + if !plan.dpb.removed.is_empty() { + aus_with_removals += 1; + } + both += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); } + assert!( + aus_with_removals > 0, + "no AU of this vector removed anything, so the zero below would be empty \ + for a reason that has nothing to do with the property being asserted" + ); + assert_eq!( + both, 0, + "{both} picture(s) are in an AU's own reference set AND removed by it. \ + That is the H.264/AV1 aliasing precondition, and HEVC is supposed to be \ + structurally incapable of it — so the snapshot in `H265Planner` has moved \ + ahead of `decode_rps`. Restore the ordering, or give this conversion the \ + `release_after_decode` deferral the other two carry; do NOT relax this \ + number" + ); + } + + /// The marked DPB as an access unit's `decode_rps` FINDS it — the set + /// `dpb_snapshot()` would return from the other side of that call. + /// + /// Exact, not approximate. `H265Planner::begin_picture` runs `decode_rps` → + /// `update_dpb_before_decoding` → `dpb_snapshot`, and the only thing between AU + /// N-1's snapshot and AU N's `decode_rps` is `finish_picture(N-1)` storing its + /// picture marked "used for short-term reference". So the pre-RPS marked set is + /// exactly `dpb_refs(N-1) ∪ {stored(N-1)}` — no DPB replay needed, and no + /// dependence on the planner internals staying reachable from a test. + /// + /// A sub-layer non-reference picture is stored but NOT marked, so it is excluded; + /// the callers assert their streams contain none, which keeps the reconstruction + /// honest rather than merely defensive. + fn pre_rps_marked(prev: Option<&AuPlan>) -> Vec { + let Some(prev) = prev else { + return Vec::new(); + }; + let mut marked = prev.dpb_refs.clone(); + if let Some(id) = prev.dpb.stored { + if prev.picture.is_reference { + marked.push(RefPic { + id, + pic_order_cnt: prev.picture.pic_order_cnt, + is_long_term: false, + }); + } + } + marked + } + + /// The exemption, measured on the stream that can actually falsify it. + /// + /// [`the_current_picture_is_named_by_curr_pic_and_never_aliases_a_reference`] runs + /// over `test-25fps.h265`, which REORDERS — a picture the RPS drops stays alive for + /// output past the access unit that dropped it, so the eviction and the unmarking + /// never land together and the vector cannot reach the precondition however hard it + /// is run. That is the same blindness that let the H.264 defect survive two + /// milestones behind a 250/250 green vector. + /// + /// This stream reaches it. Three numbers, and the third is what gives the second + /// its meaning: + /// + /// - **115 of 120** access units retire a picture (a five-picture DPB against four + /// marked references and `sps_max_num_reorder_pics = 0`); + /// - **0** of those retirements intersect the access unit's own `dpb_refs` — the + /// exemption, measured on our own encoder's output rather than argued; + /// - **115** of them intersect the PRE-RPS marked set, so a snapshot taken one call + /// earlier would alias on every single one. + /// + /// [`the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps`] + /// then drives that counterfactual through the conversion itself. + #[test] + fn the_low_delay_stream_reaches_the_dpb_pressure_and_hevc_still_does_not_alias() { + let converted = convert_stream(LOWDELAY_640X480_H265); + assert_eq!(converted.len(), 120, "the low-delay stream is 120 pictures"); + + let mut with_removals = 0usize; + let mut both = 0usize; + let mut would_alias = 0usize; + for (i, (plan, dxva)) in converted.iter().enumerate() { + // The consequence, over every access unit of the stream: the decode target + // is `CurrPic` and appears in no reference entry. + assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot); + assert!(!dxva.pic_params.CurrPic.associated()); + for r in &dxva.refs { + assert_ne!( + r.slot, dxva.setup_slot, + "AU {i}: reference picture {} shares surface {} with the decode \ + target — HEVC has acquired the H.264/AV1 defect", + r.id, r.slot + ); + } + + assert!( + plan.picture.is_reference, + "AU {i}: this stream carries no sub-layer non-reference pictures, \ + which is what makes `pre_rps_marked` exact" + ); + if !plan.dpb.removed.is_empty() { + with_removals += 1; + } + both += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); + let before = pre_rps_marked(i.checked_sub(1).map(|prev| &converted[prev].0)); + would_alias += plan + .dpb + .removed + .iter() + .filter(|id| before.iter().any(|r| r.id == **id)) + .count(); + } + + assert_eq!( + with_removals, 115, + "the stream must still retire a picture on nearly every access unit; \ + without that both numbers below are trivially zero" + ); + assert_eq!( + both, 0, + "{both} picture(s) are in an access unit's own marked DPB AND removed by \ + it — the H.264/AV1 aliasing precondition, which HEVC is supposed to be \ + structurally incapable of. `H265Planner`'s snapshot has moved ahead of \ + `decode_rps`. Restore the ordering, or give this conversion the \ + `release_after_decode` deferral the other two carry; do NOT relax this" + ); + assert_eq!( + would_alias, 115, + "the fixture must stay CAPABLE of exposing the defect it rules out. A \ + regenerated stream that reordered, or whose DPB was deeper than its \ + reference count, would report 0 here — and the zero above would then \ + prove exactly as much as `test-25fps.h264`'s zero proved, which was nothing" + ); + } + + /// The counterfactual driven through the CONVERSION, not just the planner's + /// arithmetic. + /// + /// `H265Planner` snapshotting one call earlier is a plausible refactor — it is + /// where `H264Planner` and `Av1Planner` both snapshot, and both needed + /// `release_after_decode` because of it. This test simulates exactly that by + /// handing `plan_to_dxva_h265` the PRE-RPS marked set as `dpb_refs` and nothing + /// else changed, then asserts the alias appears: the dropped picture enters + /// `RefPicList` as a *Foll* entry with its slot resolved BEFORE the removals are + /// released, `SlotMap::assign` hands that freed slot straight to `CurrPic`, and one + /// surface is named as both the decode target and a picture the frame predicts from. + /// + /// So the guarantee is not "we looked and it was fine". It is: this stream reaches + /// the shape, this conversion breaks on it under the other snapshot ordering, and + /// the ordering we ship is why it does not. + #[test] + fn the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps() { + let mut planner = H265Planner::new(); + let mut slots: Option = None; + let mut plans: Vec = Vec::new(); + let mut aliased = 0usize; + let mut converted = 0usize; + + for (i, au) in split_into_aus(LOWDELAY_640X480_H265) + .into_iter() + .enumerate() + { + let plan = planner.plan_au(au).expect("the low-delay stream plans"); + let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + + // The ONE mutation: the marked DPB as it stood before this AU's RPS ran. + let mut as_if = plan.clone(); + as_if.dpb_refs = pre_rps_marked(plans.last()); + + // The reconstruction validates itself, so a planner change that broke the + // reasoning behind `pre_rps_marked` fails HERE with its reason rather than + // quietly turning the count below into a different measurement. Marking + // only ever grows between two access units' RPS derivations (the previous + // picture is stored marked; C.5.2.2's removal takes only already-unmarked + // pictures), so the pre-RPS set is a strict SUPERSET of the post-RPS one. + for rp in plan.dpb_refs.iter().chain( + as_if + .rps + .st_curr_before + .iter() + .chain(&as_if.rps.st_curr_after) + .chain(&as_if.rps.lt_curr), + ) { + assert!( + as_if.dpb_refs.iter().any(|r| r.id == rp.id), + "AU {i}: picture {} is in the post-RPS marked DPB (or a current \ + set) but not in the reconstructed pre-RPS one — marking is no \ + longer monotone across an access unit boundary, and this test is \ + measuring a different mutation than the one it documents", + rp.id + ); + } + + let dxva = plan_to_dxva_h265(&as_if, map, i as u32 + 1).expect("conversion"); + converted += 1; + if dxva.refs.iter().any(|r| r.slot == dxva.setup_slot) { + aliased += 1; + } + plans.push(plan); + } + + assert_eq!(converted, 120); + assert_eq!( + aliased, 115, + "the pre-RPS snapshot must alias on every access unit that retires a \ + picture. {aliased} of 120 did — if this is 0, the stream no longer \ + reaches the shape and the exemption asserted by the test above is \ + unfalsifiable again; regenerate the fixture rather than relaxing this" + ); } #[test] diff --git a/crates/pf-dxvadec/tests/libav_picparams_parity.rs b/crates/pf-dxvadec/tests/libav_picparams_parity.rs index f5b770e9..ff56a630 100644 --- a/crates/pf-dxvadec/tests/libav_picparams_parity.rs +++ b/crates/pf-dxvadec/tests/libav_picparams_parity.rs @@ -145,6 +145,47 @@ //! while every other byte still looks right. If the macro is spelled differently in the tree, //! any expression yielding the negotiated config's `ConfigBitstreamRaw` will do. //! +//! **5b. AV1.** The identical `PFPP` block goes at the very END of +//! `ff_dxva2_av1_fill_picture_parameters` (`dxva2_av1.c:60`), with `h264` replaced by `av1` — +//! after the film-grain block, so every field is final. Three things differ from the two codecs +//! above and each of them changes what a capture MEANS: +//! +//! * **The AU index is a FRAME, not a temporal unit.** `ff_dxva2_common_end_frame` runs once per +//! submitted picture and an AV1 temporal unit may decode several, so `pf_au_index` walks +//! decoded frames. This crate's [`our_av1_submissions`] emits one entry per decoded frame for +//! the same reason, and the vendored vector is **274** frames in 250 units — a capture with +//! 250 `PFPP av1` lines is a capture of something else. (A `show_existing_frame` unit submits +//! nothing on either side; this vector has none.) +//! * **No `PFQM` line and no matrix buffer.** `dxva2_av1_end_frame` passes `NULL, 0` for the qm +//! pair, so the `qm_size > 0` branch of the block in step 4 logs `absent` on every frame. That +//! is the expected reading, not a missed patch site. +//! * **No `PFCFG` check.** [`preflight`]'s `ConfigBitstreamRaw` assertion is about the two short +//! slice-control formats; AV1's slice-control record is `DXVA_Tile_AV1` and has no short/long +//! pair, so a captured `PFCFG av1` line is ignored rather than compared. +//! +//! The stream is the vendored IVF at +//! `crates/pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1`: +//! +//! ```text +//! ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 -i test-25fps.ivf.av1 -f null - 2> av1.log +//! grep -oE 'PF(PP|QM|BD|CFG) .*' av1.log > libav-av1.capture +//! ``` +//! +//! ⚠ Add `-export_side_data +film_grain` to NOTHING: `apply_grain` and `pp->coding.film_grain` +//! both turn OFF when film grain is exported as side data, and this crate always applies it in +//! the decoder. The vendored vector codes no grain either way. +//! +//! **No such capture has been taken.** As of 2026-08-07 the AV1 comparison +//! (`our_av1_picture_parameters_match_libavcodecs`) has never run against libavcodec's bytes, +//! and the reason is stated rather than left as an absent result: `.221`, the only box in this +//! fleet with a D3D11VA GPU to spare, has no MSYS2, no gcc and no make, so producing a patched +//! FFmpeg there is a toolchain bring-up rather than a build. What DID localise the AV1 defect +//! of 2026-08-07 was `video_d3d11_native`'s frame-hash parity harness plus a CPU invariant +//! (`no_av1_submission_names_its_decode_surface_in_the_reference_store`, below) — so this file's +//! AV1 half is currently the no-capture half only, and every claim it makes about libavcodec's +//! AV1 side is READ out of `dxva2_av1.c` (n8.1) rather than measured. Tier one, in the +//! provenance section's terms, for all of it. +//! //! **6. Run it.** `--enable-d3d11va` is on by default on Windows. Decode the SAME elementary //! streams this test plans — the vendored vectors, in the repository at //! `crates/pf-bitstream/vendor/cros-codecs/src/codec/{h264,h265}/test_data/test-25fps.{h264,h265}`: @@ -167,6 +208,7 @@ //! //! ```text //! PF_LIBAV_CAPTURE_H264=libav-h264.capture PF_LIBAV_CAPTURE_HEVC=libav-hevc.capture \ +//! PF_LIBAV_CAPTURE_AV1=libav-av1.capture \ //! cargo test -p pf-dxvadec --test libav_picparams_parity -- --ignored --nocapture //! ``` //! @@ -352,13 +394,19 @@ use pf_dxvadec::dxva::QmatrixHevc; use pf_dxvadec::dxva::SliceH264Short; use pf_dxvadec::dxva::SliceHevcShort; use pf_dxvadec::dxva::UNUSED_ENTRY; +use pf_dxvadec::dxva_av1::PicEntryAv1; +use pf_dxvadec::dxva_av1::UNUSED_INDEX; use pf_dxvadec::AuPlan; +use pf_dxvadec::Av1Planner; use pf_dxvadec::BufferDescriptor; use pf_dxvadec::Codec; use pf_dxvadec::H264Planner; use pf_dxvadec::H265Planner; +use pf_dxvadec::PicParamsAv1; use pf_dxvadec::SliceRecord; use pf_dxvadec::SlotMap; +use pf_dxvadec::TileAv1; +use pf_dxvadec::NUM_REF_SLOTS; const TEST_25FPS_H264: &[u8] = include_bytes!( "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" @@ -366,6 +414,11 @@ const TEST_25FPS_H264: &[u8] = include_bytes!( const TEST_25FPS_H265: &[u8] = include_bytes!( "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" ); +/// The AV1 vector is an IVF container, and its unit of comparison is the TEMPORAL UNIT rather +/// than the access unit: one IVF packet may decode several frames, of which at most one shows. +const TEST_25FPS_AV1: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1" +); /// Both vendored vectors carry exactly this many access units — pf-bitstream's own golden, and /// the number of `PFPP` lines a valid capture holds. @@ -453,8 +506,12 @@ struct OurSubmission { qmatrix: Option>, /// The descriptor set, in submission order. descriptors: Vec, - /// The packer's slice records, for the internal-consistency checks. + /// The packer's slice records, for the internal-consistency checks. Empty on AV1, whose + /// slice-control buffer holds [`Self::tiles`] instead. records: Vec, + /// AV1's slice-control records — one `DXVA_Tile_AV1` per TILE, not per tile group. Empty + /// on H.264 and H.265. + tiles: Vec, /// Bytes the packer wrote BEFORE the tail padding. unpadded: u32, /// `mb_width * mb_height` (H.264) or 0 (HEVC) — the value the descriptors must carry. @@ -483,6 +540,19 @@ fn our_h264_submissions() -> Vec { // libavcodec's `1 + report_id++` produces for a decoder that saw only this stream. let dxva = pf_dxvadec::plan_to_dxva(&plan, map, out.len() as u32 + 1) .unwrap_or_else(|e| panic!("AU {i} must convert: {e}")); + // The conversion's half of the deferral contract + // ([`pf_dxvadec::DecodePlanDxva::release_after_decode`]): a loop that converts + // AU after AU without applying it holds a surface per AU and runs the ledger + // dry. The vendored vector never puts a picture in both `RefFrameList` and + // `removed`, so this list is always empty HERE — applied anyway, because a + // harness that mirrors the caller only on the streams where it does not matter + // is a harness that would not notice the caller being wrong. + for &id in &dxva.release_after_decode { + assert!( + map.release(id), + "AU {i}: a deferred release named a picture holding no surface" + ); + } let packed = pf_dxvadec::pack(au, &dxva.slice_ranges, &mut mapping) .unwrap_or_else(|e| panic!("AU {i} must pack: {e}")); let unpadded = pf_dxvadec::packed_size(au, &dxva.slice_ranges).expect("packed size") as u32; @@ -491,6 +561,7 @@ fn our_h264_submissions() -> Vec { qmatrix: Some(pf_dxvadec::as_bytes(&dxva.qmatrix).to_vec()), descriptors: pf_dxvadec::descriptors_h264(&dxva, &packed), records: packed.records, + tiles: Vec::new(), unpadded, mb_count: dxva.mb_count, }); @@ -527,6 +598,7 @@ fn our_hevc_submissions() -> Vec { .map(|qm| pf_dxvadec::as_bytes(qm).to_vec()), descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed), records: packed.records, + tiles: Vec::new(), unpadded, mb_count: 0, }); @@ -535,6 +607,85 @@ fn our_hevc_submissions() -> Vec { out } +/// Every AV1 FRAME the vendored vector decodes — 274, of which 250 are displayed. +/// +/// The unit of comparison is the frame and not the temporal unit, because that is what +/// libavcodec's hwaccel counts: `ff_dxva2_common_end_frame` runs once per submitted PICTURE, so +/// a capture's AU index walks decoded frames. A `show_existing_frame` unit submits nothing and +/// appears on neither side; this vector has none. +const VENDORED_AV1_FRAMES: usize = 274; + +/// Plan, convert and pack the whole vendored AV1 vector, one entry per decoded FRAME. +/// +/// ⚠ This is the only one of the three that has to speak the conversion's DEFERRED RELEASE +/// contract ([`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`]). A loop that converts +/// without it holds a surface on 268 of these 274 frames and runs the nine-slot ledger dry +/// inside ten — and, worse for a harness, it would compare a submission built by a caller that +/// is not the rung. +fn our_av1_submissions() -> Vec { + let mut planner = Av1Planner::new(); + let mut slots = SlotMap::new(NUM_REF_SLOTS); + let mut mapping = vec![0u8; MAPPING_BYTES]; + let mut out = Vec::new(); + for (i, unit) in split_ivf(TEST_25FPS_AV1).into_iter().enumerate() { + let plans = planner + .plan_au(unit) + .unwrap_or_else(|e| panic!("unit {i} of the vendored AV1 vector must plan: {e}")); + for plan in &plans { + if plan.dpb.stored.is_none() { + continue; // `show_existing_frame`: no submission at all + } + let dxva = pf_dxvadec::plan_to_dxva_av1(unit, plan, &mut slots) + .unwrap_or_else(|e| panic!("unit {i} must convert: {e}")); + let packed = pf_dxvadec::pack_av1(unit, &dxva.bitstream, &dxva.tiles, &mut mapping) + .unwrap_or_else(|e| panic!("unit {i} must pack: {e}")); + let unpadded = pf_dxvadec::packed_size_av1(&dxva.bitstream) as u32; + out.push(OurSubmission { + pic_params: pf_dxvadec::as_bytes(&dxva.pic_params).to_vec(), + // AV1 transmits no quantization matrix at all: its matrices are SELECTED by + // index out of tables the decoder already has, and `dxva2_av1_end_frame` + // passes `NULL, 0` for the pair. `None` here is a fact about the codec, not a + // condition on the stream the way HEVC's is. + qmatrix: None, + descriptors: pf_dxvadec::descriptors_av1(&packed), + // AV1's slice-control records are `DXVA_Tile_AV1`, a different struct with a + // different size; the shared `SliceRecord` checks do not apply to them, and + // the tile records get their own test rather than a coerced one. + records: Vec::new(), + tiles: packed.tiles.clone(), + unpadded, + mb_count: 0, + }); + for &id in &dxva.release_after_decode { + assert!( + slots.release(id), + "unit {i}: a deferred release named a picture holding no surface" + ); + } + } + } + assert_eq!(out.len(), VENDORED_AV1_FRAMES); + out +} + +/// The IVF frame walk — the same one `video_d3d11_native`'s parity module and every AV1 test in +/// this program use: a 32-byte file header, then a 12-byte header per packet carrying its size. +fn split_ivf(stream: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut at = 32usize; + while at + 12 <= stream.len() { + let size = u32::from_le_bytes([stream[at], stream[at + 1], stream[at + 2], stream[at + 3]]) + as usize; + at += 12; + if at + size > stream.len() { + break; + } + out.push(&stream[at..at + size]); + at += size; + } + out +} + // --------------------------------------------------------------------------- // Offset → field name // --------------------------------------------------------------------------- @@ -543,9 +694,12 @@ fn our_hevc_submissions() -> Vec { /// order, built from the field IDENTIFIERS so a name and the offset it reports cannot drift /// apart — the whole point of the table is to turn a differing byte into a field name, and a /// table with a copy-pasted mismatch would name the wrong one. +/// Nested paths (`tiles.cols`) are accepted as well as plain identifiers, and are named by +/// the whole path — AV1's picture parameters are eight nested blocks, and a table that could +/// only reach the outer members would report "segmentation differs" for a 140-byte struct. macro_rules! field_table { - ($ty:ty, $($field:ident),+ $(,)?) => { - &[$((stringify!($field), offset_of!($ty, $field))),+] + ($ty:ty, $($($field:ident).+),+ $(,)?) => { + &[$((stringify!($($field).+), offset_of!($ty, $($field).+))),+] }; } @@ -670,6 +824,87 @@ const HEVC_QMATRIX_FIELDS: &[(&str, usize)] = field_table!( ucScalingListDCCoefSizeID3, ); +/// Every field of `DXVA_PicParams_AV1`, same construction — but reaching INTO the eight nested +/// blocks, because they are where AV1 keeps almost the whole frame header. `tiles` alone is 260 +/// bytes and `segmentation` 140; a table stopping at the outer members would turn every finding +/// in them into one useless name. +/// +/// Three members stay whole on purpose. `frame_refs` is seven 36-byte entries which — unlike +/// the other two codecs' reference arrays — carry NO surface index and so compare byte for +/// byte: `Index` is `ref_frame_idx[name]`, an AV1 SLOT both sides read out of the same frame +/// header. `ref_frame_map_texture_index` is the surface array, which cannot be compared by +/// value at all ([`av1_reference_store`]). And `film_grain` is 158 bytes neither vendored +/// vector codes. +const AV1_FIELDS: &[(&str, usize)] = field_table!( + PicParamsAv1, + width, + height, + max_width, + max_height, + curr_pic_texture_index, + superres_denom, + bitdepth, + seq_profile, + tiles.cols, + tiles.rows, + tiles.context_update_id, + tiles.widths, + tiles.heights, + coding, + format, + primary_ref_frame, + order_hint, + order_hint_bits, + frame_refs, + ref_frame_map_texture_index, + loop_filter.filter_level, + loop_filter.filter_level_u, + loop_filter.filter_level_v, + loop_filter.sharpness_level, + loop_filter.control_flags, + loop_filter.ref_deltas, + loop_filter.mode_deltas, + loop_filter.delta_lf_res, + loop_filter.frame_restoration_type, + loop_filter.log2_restoration_unit_size, + loop_filter.reserved16, + quantization.control_flags, + quantization.base_qindex, + quantization.y_dc_delta_q, + quantization.u_dc_delta_q, + quantization.v_dc_delta_q, + quantization.u_ac_delta_q, + quantization.v_ac_delta_q, + quantization.qm_y, + quantization.qm_u, + quantization.qm_v, + quantization.reserved16, + cdef.control_flags, + cdef.y_strengths, + cdef.uv_strengths, + interp_filter, + segmentation.control_flags, + segmentation.reserved24, + segmentation.feature_mask, + segmentation.feature_data, + film_grain, + reserved32, + status_report_feedback_number, +); + +/// `DXVA_Tile_AV1` — AV1's slice-control record, and the one this crate had to derive rather +/// than measure (`dxva.h` declares it; the SIZE is what the descriptor states). +const AV1_TILE_FIELDS: &[(&str, usize)] = field_table!( + TileAv1, + data_offset, + data_size, + row, + column, + reserved16, + anchor_frame, + reserved8, +); + /// Turn a field table into `(name, byte range)`, the last field running to `total`. fn field_ranges( fields: &[(&'static str, usize)], @@ -1038,6 +1273,37 @@ fn hevc_ref_entries(pp: &[u8]) -> Vec<(u8, RefEntry)> { .collect() } +/// One side's AV1 reference store, read out of the SUBMITTED BYTES: `(CurrPicTextureIndex, +/// RefFrameMapTextureIndex[8], frame_refs[name].Index for the seven names)`. +/// +/// AV1's reference numbering is two arrays that mean different things at once and the split is +/// exactly what a comparison has to respect. `frame_refs[i].Index` is an AV1 reference SLOT — +/// `ref_frame_idx[i]`, which both sides read out of the same frame header — so it is a VALUE +/// that must match libavcodec's exactly, and it is compared as part of the `frame_refs` field. +/// `RefFrameMapTextureIndex[slot]` and `CurrPicTextureIndex` are SURFACES, which come from each +/// side's own pool and are only ever a bijection. +/// +/// So the store is compared as a SHAPE: which slots are occupied, and whether the decode target +/// collides with any of them. +fn av1_reference_store(pp: &[u8]) -> (u8, [u8; 8], [u8; 7]) { + let curr = pp[offset_of!(PicParamsAv1, curr_pic_texture_index)]; + let mut store = [UNUSED_INDEX; 8]; + let base = offset_of!(PicParamsAv1, ref_frame_map_texture_index); + store.copy_from_slice(&pp[base..base + 8]); + let mut names = [UNUSED_INDEX; 7]; + // The stride and the member offset come from the TYPE, never from the two numbers + // `dxva_av1.rs` measured (36 and 33). Those are pinned there as compile-time assertions + // against the Windows SDK's own header, and re-typing them here would be a second copy that + // can drift from the first — which for a reader of this array is the difference between a + // reference slot and a warp coefficient. + for (name, slot) in names.iter_mut().enumerate() { + *slot = pp[offset_of!(PicParamsAv1, frame_refs) + + name * size_of::() + + offset_of!(PicEntryAv1, index)]; + } + (curr, store, names) +} + /// The surface mapping between the two sides, tracked per PICTURE. /// /// A global index-to-index bijection over a whole stream is the wrong model: both sides reuse a @@ -1287,10 +1553,17 @@ fn preflight(capture: &Capture, ours: usize, codec: &str, reserved16: Option Codec::H264, - _ => Codec::H265, - }); + // + // ⚠ AV1 is exempt, and not because the check is inconvenient: `ConfigBitstreamRaw`'s short + // format is a property of the two SHORT SLICE-CONTROL structs, and AV1's slice-control + // record is `DXVA_Tile_AV1`, which has no short/long pair for a config to select between. + // Comparing an AV1 capture's number against HEVC's 1 — which a `_ =>` arm would do — is a + // check of nothing that fails on anything. + let want = match codec { + "h264" => pf_dxvadec::short_slice_config(Codec::H264), + "hevc" => pf_dxvadec::short_slice_config(Codec::H265), + _ => return, + }; for (au, &raw) in &capture.config_bitstream_raw { assert_eq!( raw, want, @@ -1568,6 +1841,108 @@ fn hevc_rps_pictures(pp: &[u8], array: usize, entries: &[(u8, RefEntry)]) -> Vec .collect() } +/// The whole AV1 picture-parameter comparison. +/// +/// Structurally simpler than the other two and the reason is worth stating: AV1 puts NO surface +/// index in its reference entries. `frame_refs[i].Index` is `ref_frame_idx[i]`, an AV1 SLOT both +/// sides read out of the same frame header, so the seven 36-byte entries — sizes, warp +/// parameters, warp type and slot alike — compare byte for byte with no re-indexing, no set +/// comparison and no allowance. Only two members carry surfaces, and they are handled as the +/// SHAPE of the store rather than by value ([`av1_reference_store`]). +/// +/// There is no POC base to derive either: AV1's `order_hint` is a coded field, not a decoder's +/// running count, so libavcodec has nothing to seed it with. +/// +/// ⚠ **`width`/`height` is a divergence waiting to be measured, and this comparison will +/// report it rather than absorb it.** libavcodec sends `avctx->width`, which +/// `update_context_with_frame_header` sets from `frame_width_minus_1 + 1` — FrameWidth, the +/// PRE-superres coded width — and the same for `frame_refs[i].width` off the reference's +/// `AVFrame`; this crate sends `UpscaledWidth`. With superres off the two are equal by +/// definition (7.20), which is every frame of the vendored vector and every frame a punktfunk +/// host emits, so a capture made from this vector cannot tell them apart. Deliberately given no +/// allowance: if a superres capture ever reaches this harness, the difference must be a finding +/// somebody reads, not a line somebody already excused. See `pic_av1.rs`'s note at `pp.width`. +fn compare_av1_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings { + let ranges = field_ranges(AV1_FIELDS, size_of::()); + // The two surface arrays, and nothing else: every other byte of this struct is a fact about + // the bitstream that both sides derive from the same frame header. + let structural = ["curr_pic_texture_index", "ref_frame_map_texture_index"]; + let mut findings = Findings::default(); + for (au, sub) in ours.iter().enumerate() { + let Some(theirs) = capture.pic_params.get(&au) else { + findings.note( + "", + au, + "the capture holds no PFPP line for this frame", + ); + continue; + }; + if theirs.len() != sub.pic_params.len() { + findings.note( + "", + au, + format!( + "the capture's picture parameters are {} bytes and ours are {}", + theirs.len(), + sub.pic_params.len() + ), + ); + continue; + } + compare_scalars( + au, + &sub.pic_params, + theirs, + &ranges, + &structural, + no_allowance, + &mut findings, + ); + + // The store, as a shape. Which SLOTS hold a picture is a fact about the bitstream and + // must agree; which SURFACE each holds is each side's own pool and never can. + let (our_curr, our_store, _) = av1_reference_store(&sub.pic_params); + let (their_curr, their_store, _) = av1_reference_store(theirs); + for slot in 0..8 { + let ours_occupied = our_store[slot] != UNUSED_INDEX; + let theirs_occupied = their_store[slot] != UNUSED_INDEX; + if ours_occupied != theirs_occupied { + findings.note( + format!("ref_frame_map_texture_index[{slot}][occupied]"), + au, + format!("ours {ours_occupied}, libav {theirs_occupied}"), + ); + } + } + // The decode target must hold no store entry's surface. libavcodec cannot produce a + // collision — it fills the store from `h->ref[i]`, which the reference update has not + // run on yet, and takes `CurrPicTextureIndex` from `h->cur_frame.f` — so a collision on + // our side is a defect however the surfaces are numbered. This is the check that names + // the 2026-08-07 defect. + // + // ⚠ Note what is deliberately NOT checked: that two slots hold different surfaces. One + // picture in several reference slots is ordinary AV1 and this very vector does it — + // the key frame sits in BWDREF and ALTREF2 for the stream's whole length — so a + // "duplicate surface" check would fire on 273 of 274 frames of a correct conversion. + for (label, curr, store) in [ + ("ours", our_curr, our_store), + ("libav", their_curr, their_store), + ] { + if store.contains(&curr) { + findings.note( + "curr_pic_texture_index[aliases the store]", + au, + format!( + "{label}: surface {curr} is both the decode target and a reference \ + store entry — the frame decodes into a picture it predicts from" + ), + ); + } + } + } + findings +} + /// The whole HEVC picture-parameter comparison. fn compare_hevc_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings { let ranges = field_ranges(HEVC_FIELDS, size_of::()); @@ -1970,6 +2345,13 @@ fn every_hand_declared_dxva_struct_is_tiled_exactly_by_its_fields() { HEVC_SLICE_FIELDS, size_of::(), ), + // AV1's two. `PicParamsAv1` is the one struct in this crate whose offsets were + // MEASURED rather than mirrored — `layout-probe-av1.c` compiled with MSVC against the + // Windows SDK's own `dxva.h` — and `dxva_av1.rs` pins every one at compile time. What + // this adds is the other half: that the TABLE above reaches all 912 bytes, so a + // capture comparison can name every one of them. + ("PicParamsAv1", AV1_FIELDS, size_of::()), + ("TileAv1", AV1_TILE_FIELDS, size_of::()), ] { assert_eq!(fields[0].1, 0, "{what}: the first field must start at 0"); let ranges = field_ranges(fields, total); @@ -2006,6 +2388,196 @@ fn every_h264_au_submits_four_buffers_in_libavcodecs_order() { } } +/// **No AV1 submission names its decode surface anywhere in the reference store**, and every +/// reference NAME resolves through a slot that holds one. +/// +/// This is the defect the Windows parity harness caught on 2026-08-07 and the one nothing on +/// the CPU could see, stated over the SUBMITTED BYTES — which is where a libavcodec capture +/// would see it too, and the reason it belongs in this file as well as in `pic_av1`'s own +/// tests. `plan_to_dxva_av1` released the picture this frame's own `refresh_frame_flags` +/// displaces before assigning the decode target a slot, and `SlotMap::assign` hands back the +/// slot just vacated — so `CurrPicTextureIndex` and one `RefFrameMapTextureIndex` entry were +/// the same surface on 268 of these 274 frames: decode into the picture you predict from. +/// Intel Arc followed the aliased surface and got 245 of 250 delivered frames wrong; NVIDIA +/// tolerated it for 63 frames and then lost one 16x24 luma block at the `order_hint` wrap. +/// +/// libavcodec cannot produce this shape and that is the whole argument for calling it a defect +/// rather than a convention: `ff_dxva2_av1_fill_picture_parameters` fills +/// `RefFrameMapTextureIndex` from `h->ref[i]`, the pre-refresh store, and takes +/// `CurrPicTextureIndex` from `h->cur_frame.f`, a frame the reference-frame update has not run +/// on yet. The two cannot be one surface. +/// +/// The counts are asserted, not printed. At zero references this test would pass against a +/// conversion that named nothing at all. +#[test] +fn no_av1_submission_names_its_decode_surface_in_the_reference_store() { + let subs = our_av1_submissions(); + let (mut with_store, mut named_refs) = (0usize, 0usize); + for (frame, sub) in subs.iter().enumerate() { + let (curr, store, names) = av1_reference_store(&sub.pic_params); + assert!( + store.iter().all(|surface| *surface != curr), + "frame {frame}: surface {curr} is both CurrPicTextureIndex and a \ + RefFrameMapTextureIndex entry" + ); + if store.iter().any(|s| *s != UNUSED_INDEX) { + with_store += 1; + } + for (name, slot) in names.iter().enumerate() { + if *slot == UNUSED_INDEX { + continue; + } + named_refs += 1; + assert!( + usize::from(*slot) < store.len(), + "frame {frame}, reference name {name}: slot {slot} is outside the eight-entry \ + store — `Index` is an AV1 reference SLOT, not a surface" + ); + assert_ne!( + store[usize::from(*slot)], + UNUSED_INDEX, + "frame {frame}, reference name {name}: slot {slot} holds no surface, so the \ + driver would follow `Index` into an empty entry" + ); + } + } + assert_eq!( + with_store, 273, + "every frame but the opening key frame carries a populated reference store" + ); + assert!( + named_refs > 0, + "no frame named a reference, so every check above was skipped" + ); +} + +/// AV1 submits THREE buffers and never a quantization matrix, on every frame of the vector. +/// +/// The codec asymmetry the H.264 and HEVC tests above are about, taken to its third case. +/// H.264 submits the matrix unconditionally, HEVC only under `scaling_list_enabled_flag`, and +/// AV1 has no matrix BUFFER at all: its quantiser matrices are SELECTED by index +/// (`qm_y`/`qm_u`/`qm_v`) out of tables the decoder already holds, and `dxva2_av1_end_frame` +/// passes `NULL, 0` for the qm pair so the generic layer submits nothing. A fourth descriptor +/// here would be a buffer the driver has no `DXVA_Qmatrix_AV1` to read it as. +#[test] +fn every_av1_frame_submits_three_buffers_and_never_a_quantization_matrix() { + for (frame, sub) in our_av1_submissions().iter().enumerate() { + assert_eq!( + sub.descriptors + .iter() + .map(|d| d.buffer_type) + .collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ], + "frame {frame}" + ); + assert!(sub.qmatrix.is_none(), "frame {frame}"); + } +} + +/// No AV1 descriptor carries a macroblock count. AV1 has no macroblocks and `dxva2_av1.c` +/// never touches the field — the same statement `no_hevc_descriptor_ever_carries_a_macroblock_count` +/// makes, and the same defect class review 13 found on the H.264 side in the other direction. +#[test] +fn no_av1_descriptor_ever_carries_a_macroblock_count() { + for (frame, sub) in our_av1_submissions().iter().enumerate() { + assert_eq!(sub.mb_count, 0, "frame {frame}"); + for desc in &sub.descriptors { + assert_eq!( + desc.num_mbs_in_buffer, + 0, + "frame {frame}, {}", + buffer_name(desc.buffer_type) + ); + } + } +} + +/// AV1's slice-control buffer is `16 * tile count`, its bitstream descriptor is the packer's +/// PADDED size, and the tile records tile the unpadded window exactly — in order, without gaps +/// and without overlaps. +/// +/// The last part is what distinguishes AV1 from the other two codecs here and is the reason +/// this cannot reuse `the_bitstream_descriptor_is_the_packers_padded_size_and_the_slice_records_tile_it_exactly`: +/// a `DXVA_Tile_AV1` addresses a TILE PAYLOAD, which is the bytes after that tile's +/// `tile_size_minus_1` field — so consecutive records are separated by those size fields and do +/// NOT abut, unlike H.264/HEVC slice records which tile their buffer with no gaps. What must +/// hold is weaker and still exact: strictly increasing, non-overlapping, inside the unpadded +/// window, and never starting at a tile-group OBU's first byte (which would hand the driver an +/// OBU header as entropy-coded tile data). +/// +/// ⚠ The padding is charged to NO record. H.264 and HEVC add the tail padding to their last +/// slice record's `SliceBytesInBuffer`; `pack_av1` does not, because a tile's size is the +/// tile's, and the descriptor is the only place AV1's padding is accounted at all. +#[test] +fn the_av1_bitstream_descriptor_is_padded_and_the_tile_records_tile_it_without_overlapping() { + let mut frames_with_padding = 0usize; + for (frame, sub) in our_av1_submissions().iter().enumerate() { + let bitstream = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_BITSTREAM) + .unwrap_or_else(|| panic!("frame {frame} submits no bitstream buffer")); + assert_eq!( + bitstream.data_size % 128, + 0, + "frame {frame}: the bitstream descriptor states the PADDED size" + ); + // ⚠ `1..=128`, not `0..128`. `pack_av1` writes libavcodec's expression verbatim — + // `BITSTREAM_ALIGN - (cursor % BITSTREAM_ALIGN)` — so data that is ALREADY on the + // granule gets a whole 128-byte block rather than none (`pack_av1`'s + // `data_already_on_the_granule_still_gets_a_full_padding_block`). This vector never + // lands on the granule, which is exactly why the bound has to come from the rule and + // not from the measurement. + let padding = bitstream.data_size - sub.unpadded; + assert!( + (1..=128).contains(&padding), + "frame {frame}: {padding} bytes of padding" + ); + frames_with_padding += 1; + + let slice_control = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_SLICE_CONTROL) + .unwrap_or_else(|| panic!("frame {frame} submits no slice-control buffer")); + assert_eq!( + slice_control.data_size as usize, + size_of::() * sub.tiles.len(), + "frame {frame}: sixteen bytes per TILE" + ); + assert!(!sub.tiles.is_empty(), "frame {frame}: a frame has tiles"); + + let mut previous_end = 0u32; + for (i, tile) in sub.tiles.iter().enumerate() { + // `#[repr(packed)]` — copy the fields out before using them. + let (offset, size) = (tile.data_offset, tile.data_size); + assert!(size > 0, "frame {frame}, tile {i}: an empty tile payload"); + assert!( + offset >= previous_end, + "frame {frame}, tile {i}: starts at {offset}, inside the previous tile which \ + ends at {previous_end}" + ); + assert!( + offset + size <= sub.unpadded, + "frame {frame}, tile {i}: runs past the bytes the packer wrote" + ); + previous_end = offset + size; + assert_eq!( + tile.anchor_frame, UNUSED_INDEX, + "frame {frame}, tile {i}: large-scale-tile anchors are libavcodec's 0xFF" + ); + } + } + assert_eq!( + frames_with_padding, VENDORED_AV1_FRAMES, + "every frame is padded — the rule is unconditional" + ); +} + #[test] fn every_h264_bitstream_and_slice_control_descriptor_carries_mb_width_times_mb_height() { // Review 13's defect, over the whole vector: `NumMBsInBuffer` was 0 where libavcodec's @@ -2281,6 +2853,7 @@ fn the_picture_parameter_buffer_is_the_whole_hand_declared_struct_for_both_codec for (codec, subs, size) in [ ("h264", our_h264_submissions(), size_of::()), ("hevc", our_hevc_submissions(), size_of::()), + ("av1", our_av1_submissions(), size_of::()), ] { for (au, sub) in subs.iter().enumerate() { assert_eq!(sub.pic_params.len(), size, "{codec} AU {au}"); @@ -2347,6 +2920,7 @@ fn hevc_case(enabled: bool, sps_coded: Option, pps_coded: Option) -> Our .map(|qm| pf_dxvadec::as_bytes(qm).to_vec()), descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed), records: packed.records, + tiles: Vec::new(), unpadded, mb_count: 0, } @@ -2508,6 +3082,35 @@ fn the_dump_and_the_parser_agree_and_the_comparison_finds_nothing_against_oursel // The HEVC matrices are `absent` on this vector, and the parser must carry that fact rather // than losing it — the whole of review 13's defect is the difference between the two. assert!(capture.qmatrix.values().all(Option::is_none)); + + // AV1, on all 274 FRAMES. Nothing here has ever been run against libavcodec's own bytes + // (module docs say why), so this self-comparison is the only thing standing between + // `compare_av1_picparams` and a first capture: it proves the 912-byte field table reaches + // every byte, that `av1_reference_store` reads `CurrPicTextureIndex` and the eight-entry + // store from the offsets it thinks it does — a wrong one would report a false alias on a + // correct submission — and that the comparison invents nothing on identical input. + let ours = our_av1_submissions(); + let capture = parse_capture(&dump("av1", &ours), "av1"); + preflight(&capture, ours.len(), "av1", None); + assert_eq!(capture.pic_params.len(), VENDORED_AV1_FRAMES); + for findings in [ + compare_av1_picparams(&ours, &capture), + compare_descriptors(&ours, &capture), + ] { + assert!( + findings.is_empty(), + "comparing our own AV1 bytes against themselves must find nothing, got {:?}", + findings.fields() + ); + assert!( + findings.documented_fields().is_empty(), + "identical bytes documented a divergence: {:?}", + findings.documented_fields() + ); + } + // AV1 reports `absent` on every frame — it has no matrix BUFFER at all, unlike HEVC where + // the same spelling is a per-sequence decision. + assert!(capture.qmatrix.values().all(Option::is_none)); } /// A submission holding only what [`compare_descriptors`] reads, for the bitstream-size @@ -2527,6 +3130,7 @@ fn descriptor_only_submission(unpadded: u32, padded: u32, slices: usize) -> OurS OurSubmission { pic_params: vec![0u8; size_of::()], qmatrix: None, + tiles: Vec::new(), descriptors: vec![ BufferDescriptor { buffer_type: BUFFER_BITSTREAM, @@ -2698,6 +3302,7 @@ fn the_hevc_tiles_flag_allowance_is_exactly_bit_ten_with_tiles_disabled_and_noth qmatrix: sub.qmatrix.clone(), descriptors: sub.descriptors.clone(), records: sub.records.clone(), + tiles: sub.tiles.clone(), unpadded: sub.unpadded, mb_count: sub.mb_count, } @@ -3080,12 +3685,28 @@ fn renumber_h264_surfaces(pp: &[u8], f: impl Fn(u8) -> u8) -> Vec { /// Emit this crate's whole submission — both codecs — in the capture's own format, so the two /// files can be diffed by any tool without a capture at all. +#[test] +#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_AV1= (see the module docs)"] +fn our_av1_picture_parameters_match_libavcodecs() { + let capture = capture_from_env("PF_LIBAV_CAPTURE_AV1", "av1") + .expect("PF_LIBAV_CAPTURE_AV1= names a capture (see the module docs)"); + let ours = our_av1_submissions(); + // No `Reserved16Bits` preflight: both libavcodec workarounds are H.264-only + // (`dxva2_h264.c`), and `DXVA_PicParams_AV1` has no such field to test. + preflight(&capture, ours.len(), "av1", None); + compare_av1_picparams(&ours, &capture).verdict("AV1 picture parameters", ours.len()); +} + #[test] #[ignore = "writes a dump: PF_DXVA_DUMP="] fn dump_our_submission_in_the_captures_own_format() { let path = std::env::var("PF_DXVA_DUMP").expect("PF_DXVA_DUMP= names the output file"); let mut text = dump("h264", &our_h264_submissions()); text.push_str(&dump("hevc", &our_hevc_submissions())); + // AV1 too, and it is the codec that needs this most: no libavcodec AV1 capture has + // ever been taken (module docs say why), so for that codec this dump is the only + // way to read what the driver is being handed at all. + text.push_str(&dump("av1", &our_av1_submissions())); std::fs::write(&path, text).expect("write the dump"); println!("wrote {path}"); } diff --git a/crates/pf-encode/src/enc/nvenc_core.rs b/crates/pf-encode/src/enc/nvenc_core.rs index a20cd47d..242230f9 100644 --- a/crates/pf-encode/src/enc/nvenc_core.rs +++ b/crates/pf-encode/src/enc/nvenc_core.rs @@ -137,6 +137,36 @@ pub(super) fn subframe_env_forced() -> bool { /// drop-in alternative — `poll_chunk` cuts at `bitstreamSizeInBytes` on the reasoning that /// "slices are contiguous Annex-B", which AV1's OBUs are not. /// +/// # A tile-aware chunk reader was considered and is CLOSED, not deferred +/// +/// The obvious follow-up is to teach the reader AV1's units — cut on OBU boundaries instead +/// of byte counts and arm from the driver's reported unit count — so AV1 gets the sub-frame +/// latency win HEVC gets (ship tile 1 while tile 2 encodes). Measured on `.21` (RTX 5070 Ti, +/// `av1_nvenc`, 2026-08-07) before writing any of it, and the measurement closes it: +/// +/// * **4K carries two tiles, and they share ONE Tile Group OBU.** The frame header reads +/// `width_in_sbs_minus_1[0] = 59` (one tile column, the full 3840) and +/// `height_in_sbs_minus_1[] = {16, 16}` (two tile rows) — but +/// `tile_start_and_end_present_flag = 0`, which puts both tiles in a single Tile Group +/// OBU. There is no OBU boundary between them to cut on. Shipping tile 1 early would mean +/// the HOST re-authoring AV1 syntax per chunk — synthesising a fresh Tile Group OBU header +/// with `tile_start_and_end_present_flag = 1` and its own `tg_start`/`tg_end` — which is +/// bitstream surgery on the encode path, not a reader change. +/// * **1080p carries one tile** (`tile_cols_log2 = tile_rows_log2 = 0`), so there is nothing +/// to pipeline at the commonest streaming resolution regardless. +/// * **The prize is small even at 4K, because split encode already spent it.** The two tile +/// rows go to two split-encode engines that run CONCURRENTLY, so they finish at nearly the +/// same moment: the win is bounded by the skew between engines, not by half the frame. +/// Whole-frame encode measures 3.3–3.6 ms at 4K60 against a 16.7 ms p50 end-to-end, so +/// even the sequential-tiles fantasy caps out near 1.7 ms and the real figure is a +/// fraction of that. HEVC's sub-frame win is larger for a structural reason that does not +/// transfer: forced split and sub-frame are mutually unsupported (below), so HEVC's slices +/// really are produced one after another. +/// +/// Reopen only if NVENC starts emitting one OBU per tile, or sets +/// `tile_start_and_end_present_flag = 1` — at that point the cut points exist and the reader +/// change becomes the small piece it was assumed to be. +/// /// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back /// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave /// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without diff --git a/crates/pf-vaadec/layout-probe.c b/crates/pf-vaadec/layout-probe.c index c09b975a..a46dea78 100644 --- a/crates/pf-vaadec/layout-probe.c +++ b/crates/pf-vaadec/layout-probe.c @@ -505,5 +505,53 @@ int main(void) { S(VAConfigAttrib); O(VAConfigAttrib, type); O(VAConfigAttrib, value); + + /* + * The IMAGE pair — `vaDeriveImage` / `vaCreateImage` + `vaGetImage` write these, + * and they are the only way anything reads a decoded VAAPI surface back on the + * CPU. `VAImage` is the awkward one of the whole file: `width` and `height` are + * `unsigned short`, so the four-byte fields around them are NOT where counting + * 32-bit words would put them, and `component_order` is four `char` rather than a + * padded word. Both are measured here rather than reasoned about. + * + * ⚠ The readback these describe is TEST-ONLY (see `video_vaapi_native`'s `parity` + * module). The production path exports a DRM-PRIME dmabuf and never maps a + * surface; the structures are declared for the same reason every other structure + * in this file is, so a parity harness can be written without a libva build + * dependency. + */ + S(VAImageFormat); + O(VAImageFormat, fourcc); + O(VAImageFormat, byte_order); + O(VAImageFormat, bits_per_pixel); + O(VAImageFormat, depth); + O(VAImageFormat, red_mask); + O(VAImageFormat, green_mask); + O(VAImageFormat, blue_mask); + O(VAImageFormat, alpha_mask); + O(VAImageFormat, va_reserved); + + S(VAImage); + O(VAImage, image_id); + O(VAImage, format); + O(VAImage, buf); + O(VAImage, width); + O(VAImage, height); + O(VAImage, data_size); + O(VAImage, num_planes); + O(VAImage, pitches); + O(VAImage, offsets); + O(VAImage, num_palette_entries); + O(VAImage, entry_bytes); + O(VAImage, component_order); + O(VAImage, va_reserved); + printf("count VAImage pitches %zu\n", + sizeof(((VAImage *)0)->pitches) / sizeof(((VAImage *)0)->pitches[0])); + printf("count VAImage offsets %zu\n", + sizeof(((VAImage *)0)->offsets) / sizeof(((VAImage *)0)->offsets[0])); + printf("count VAImage component_order %zu\n", + sizeof(((VAImage *)0)->component_order)); + printf("enum VA_LSB_FIRST %d\n", VA_LSB_FIRST); + printf("enum VA_MSB_FIRST %d\n", VA_MSB_FIRST); return 0; } diff --git a/crates/pf-vaadec/src/lib.rs b/crates/pf-vaadec/src/lib.rs index 10e424b5..539797d0 100644 --- a/crates/pf-vaadec/src/lib.rs +++ b/crates/pf-vaadec/src/lib.rs @@ -23,10 +23,18 @@ //! everything decidable without a device — including [`drm`], the export //! descriptor the driver writes back and the plane walk that reads it. //! -//! ⚠ **Nothing here has decoded a frame.** The rung is pin-only -//! (`PUNKTFUNK_DECODER=native-vaapi`) and no VAAPI hardware has been reachable -//! during M7, so everything below is a CPU-side conversion checked against -//! libavcodec and against measured layouts, not against a picture. +//! **Every conversion in this crate has now been checked in PIXELS.** On 2026-08-08, +//! on `.25` (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23), +//! `pf-client-core`'s `video_vaapi_native::parity` decoded seven streams through the +//! rung and hashed every delivered frame against libavcodec's software decode — the +//! same golden files the Vulkan and D3D11VA rungs are held to — and all seven came +//! back bit-identical: 250 + 120 H.264, 250 + 120 H.265, 50 HEVC Main 10 (P010), and +//! 250 + 60 AV1. That was possible at all because [`va::pack_two_plane`] and the +//! `VAImage` pair below give a TEST-ONLY readback of a decoded surface; nothing on the +//! production path maps one, and that module's docs say how it is kept that way. +//! +//! ⚠ ONE vendor. AMD/radeonsi only — Intel's iHD driver has neither run these legs nor +//! been asked to. //! //! Five things this crate settled that a reader would otherwise have to re-derive: //! @@ -150,3 +158,19 @@ pub use va::VaIqMatrixBufferH264; pub use va::VaPictureH264; pub use va::VaPictureParameterBufferH264; pub use va::VaSliceParameterBufferH264; + +// The CPU-readable view of a decoded surface, and the pure walk that packs one into +// the layout this program's goldens hash. +// +// ⚠ TEST-ONLY. Nothing on the production video path maps a surface — the rung exports +// a DRM-PRIME dmabuf and the presenter samples it, which is the zero-copy contract — +// so the only caller is `pf-client-core`'s `video_vaapi_native::parity`, which exists +// solely under `#[cfg(test)]`. These are declared here so that harness needs no +// `libva-dev` and so its geometry can be checked with no device at all (`va`'s module +// docs say why at length). +pub use va::pack_two_plane; +pub use va::packed_len; +pub use va::ImageReadError; +pub use va::VaImage; +pub use va::VaImageFormat; +pub use va::VA_LSB_FIRST; diff --git a/crates/pf-vaadec/src/pic.rs b/crates/pf-vaadec/src/pic.rs index a66d3825..5da7957e 100644 --- a/crates/pf-vaadec/src/pic.rs +++ b/crates/pf-vaadec/src/pic.rs @@ -549,6 +549,20 @@ mod tests { "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" ); + /// A punktfunk HOST's own output: 120 pictures of 640x480, `max_num_ref_frames = 3` + /// alongside `max_dec_frame_buffering = 3` and `max_num_reorder_frames = 0`. + /// + /// Vendored beside `pf-vkdecode`'s per-frame goldens, and the only stream in the + /// tree that produces the shape this module's exemption is about. The conformance + /// vector above cannot: its level gives it a 7-frame DPB against 2 reference + /// frames, so 8.2.5's sliding window unmarks a picture two access units before + /// C.4.5.3's bump can evict it, and it reorders, which keeps an unmarked picture + /// alive past the unit that unmarked it. Both are properties of that vector rather + /// than of H.264, and between them they hid a defect that fired on 297 of 300 + /// access units of every stream we ship, on two other backends, for two milestones. + const LOWDELAY_640X480: &[u8] = + include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264"); + /// Minimal H.264 access-unit splitter. The production wire delivers whole access /// units, so pf-bitstream keeps its splitter test-only; this is the same rule — /// a new AU begins at a non-VCL NALU following slices, or at a slice declaring @@ -693,83 +707,311 @@ mod tests { ); } - /// The decode target must never be a surface this same access unit READS. + /// What one walk of a stream through [`plan_to_va`] measured. /// - /// This is the question a slot ledger cannot answer, and it is why the caller - /// binds the setup surface instead of the conversion reading one out of a - /// slot-indexed table. + /// Every field is a count of ACCESS UNITS, so the four are directly comparable and + /// each is bounded by [`Self::converted`]. + #[derive(Debug, Default)] + struct AliasWalk { + /// Access units planned and converted. + converted: usize, + /// The setup picture was assigned a slot this access unit's OWN removals had + /// just freed. `SlotMap::assign` takes the lowest free slot, so this is the + /// ordinary case rather than an edge one — and it is why a decode target read + /// out of a slot-indexed table would be the surface of the picture just + /// displayed. + inherited_a_just_freed_slot: usize, + /// This access unit's own `removed` list names a picture its `dpb_refs` + /// snapshot also names: 8.2.5's sliding window unmarked a reference in the very + /// unit whose C.4.5.3 bump evicted it. The aliasing PRECONDITION, and the shape + /// the vendored conformance vector never produces. + removed_and_referenced: usize, + /// The setup picture took the slot of a picture this same access unit READS. + /// This is the D3D11VA/Vulkan defect verbatim — `CurrPic` and a reference entry + /// resolving through one slot — and on those two backends the surface followed + /// the slot, so the submission aliased. Here the surface does not follow the + /// slot, which is what [`Self::aliased`] measures. + setup_took_a_read_pictures_slot: usize, + /// The submission names the decode target as one of its own references, in + /// `reference_frames` or in any slice's `RefPicList0`/`1`. Must be zero. + aliased: usize, + } + + /// Drive `stream` through the planner and [`plan_to_va`], modelling the caller the + /// way the Linux rung is written, and count the four shapes above. /// - /// `SlotMap::assign` takes the LOWEST free slot, and a slot freed by this - /// access unit's own removals is free by the time the setup picture is - /// assigned. Measured on the vendored vector, that is not an edge case: the - /// setup picture inherits a just-freed slot on **225 of 250** access units. - /// A surface bound BY SLOT would therefore decode, on nine frames in ten, - /// into the surface still holding the picture that was just displayed — which - /// under zero-copy the consumer may still be sampling. Hence the pool model - /// this crate's callers use, and hence `setup_surface`. - /// - /// The second half of the test is the reassurance that comes with it: given - /// the caller's contract (a surface bound to no live picture), the decode - /// target is never a surface the same access unit READS. That is checked - /// against both readable sets, which are not the same snapshot — `dpb_refs` is - /// taken after this AU's marking process, the per-slice lists before it. - #[test] - fn the_setup_picture_routinely_inherits_a_just_freed_slot() { + /// The model is one line and it is the whole contract: the decode target is a + /// surface that **is not in the table the conversion is handed**, and it enters + /// that table only after the conversion returns. `video_vaapi_native`'s + /// `the_low_delay_stream_never_hands_the_decoder_a_surface_it_is_predicting_from` + /// is the same walk driven through the REAL `Session` pool, which is what says the + /// rung honours the contract; this one says what the contract buys. + fn walk_for_aliasing(stream: &[u8]) -> AliasWalk { use pf_bitstream::h264::H264Planner; - let aus = split_aus(TEST_25FPS_H264); let mut planner = H264Planner::new(); - let mut surfaces: Vec = Vec::new(); let mut slots: Option = None; - let mut collisions = 0usize; - let mut first: Option = None; - let mut inherited = 0usize; + // Slot to surface — precisely `Session::surface_table()` on the Linux rung. + let mut table: Vec = Vec::new(); + let mut out = AliasWalk::default(); - for (index, au) in aus.iter().enumerate() { - let plan = planner.plan_au(au).expect("the clean vector plans"); + for (index, au) in split_aus(stream).into_iter().enumerate() { + let plan = planner + .plan_au(au) + .unwrap_or_else(|e| panic!("AU {index}: this stream must plan, got {e:?}")); let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); - surfaces.resize(map.capacity(), VA_INVALID_SURFACE); - // Which slots this AU's own removals will free — read BEFORE the - // conversion applies them, because afterwards the ledger has forgotten. + assert_eq!( + map.capacity(), + plan.picture.max_dpb_frames + 1, + "AU {index}: neither stream renegotiates its DPB depth mid-walk" + ); + table.resize(map.capacity(), VA_INVALID_SURFACE); + + if plan + .dpb + .removed + .iter() + .any(|id| plan.dpb_refs.iter().any(|r| r.id == *id)) + { + out.removed_and_referenced += 1; + } + // Which slots this AU's removals will free, read BEFORE the conversion + // applies them — afterwards the ledger has forgotten. let freed: Vec = plan .dpb .removed .iter() .filter_map(|id| map.slot_of(*id)) .collect(); + + // Ids start well away from slot indices and are never reused, so a stale or + // aliased reference shows up as a value rather than as a plausible-looking + // off-by-one and cannot hide behind a surface that happens to be right + // again. The assertion is the model's own precondition: a target the table + // already names would beg the question this walk exists to answer. let setup_surface = SURFACE_BASE + index as u32; - let out = plan_to_va(&plan, au, map, &surfaces, setup_surface) - .expect("the clean vector converts"); - surfaces[usize::from(out.setup_slot)] = setup_surface; - if freed.contains(&out.setup_slot) { - inherited += 1; + assert!( + !table.contains(&setup_surface), + "AU {index}: the model handed out a surface the table already names" + ); + let displaced = table.clone(); + let converted = plan_to_va(&plan, au, map, &table, setup_surface) + .unwrap_or_else(|e| panic!("AU {index}: conversion failed: {e}")); + table[usize::from(converted.setup_slot)] = setup_surface; + + // Both readable sets, and they are not the same snapshot: `dpb_refs` is + // taken after this AU's marking process, the per-slice lists before it. + let named: Vec = converted + .pic_params + .reference_frames + .iter() + .chain( + converted + .slices + .iter() + .flat_map(|s| s.ref_pic_list0.iter().chain(s.ref_pic_list1.iter())), + ) + .filter(|e| e.flags & VA_PICTURE_H264_INVALID == 0) + .map(|e| e.picture_id) + .collect(); + + if freed.contains(&converted.setup_slot) { + out.inherited_a_just_freed_slot += 1; } - let curr = out.pic_params.curr_pic.picture_id; - let names = - |e: &VaPictureH264| e.flags & VA_PICTURE_H264_INVALID == 0 && e.picture_id == curr; - let read_by_this_au = out.pic_params.reference_frames.iter().any(names) - || out.slices.iter().any(|s| { - s.ref_pic_list0.iter().any(names) || s.ref_pic_list1.iter().any(names) - }); - if read_by_this_au { - collisions += 1; - first.get_or_insert(index); + let evicted_surface = displaced[usize::from(converted.setup_slot)]; + if evicted_surface != VA_INVALID_SURFACE && named.contains(&evicted_surface) { + out.setup_took_a_read_pictures_slot += 1; } + assert_eq!( + converted.pic_params.curr_pic.picture_id, setup_surface, + "AU {index}: the current picture must be the surface the caller bound" + ); + if named.contains(&setup_surface) { + out.aliased += 1; + } + out.converted += 1; } - // The measurement this design rests on. A floor rather than the exact - // count, so a planner change that shifts it by a frame does not fail — - // but one that made slot reuse RARE would, and would mean the doc above - // has stopped being true. + out + } + + /// The setup picture routinely inherits a slot its own access unit just freed — + /// which is why the decode target is a PARAMETER and not `surfaces[setup_slot]`. + /// + /// `SlotMap::assign` takes the LOWEST free slot, and a slot freed by this access + /// unit's own removals is free by the time the setup picture is assigned. Measured + /// on the vendored vector that is not an edge case: **225 of 250** access units. A + /// surface bound BY SLOT would therefore decode, on nine frames in ten, into the + /// surface still holding the picture that was just displayed — which under + /// zero-copy the consumer may still be sampling. Hence the pool model this crate's + /// callers use, and hence `setup_surface`. + /// + /// ⚠ This test used to carry a second half asserting the decode target was never + /// also a reference. It was VACUOUS: the walk hands every picture its own + /// never-reused surface id, so distinct ids cannot collide and the assertion could + /// not fail whatever the conversion did. The real question needs a surface pool + /// that RECYCLES, and it is answered by the two tests below and by + /// `video_vaapi_native`'s walk through the real one. + #[test] + fn the_setup_picture_routinely_inherits_a_just_freed_slot() { + let walk = walk_for_aliasing(TEST_25FPS_H264); + assert_eq!(walk.converted, 250); + // A floor rather than the exact count, so a planner change that shifts it by a + // frame does not fail — but one that made slot reuse RARE would, and would mean + // the documentation citing this number has stopped being true. assert!( - inherited > 200, - "the setup picture inherited a just-freed slot on only {inherited} of 250 access \ + walk.inherited_a_just_freed_slot > 200, + "the setup picture inherited a just-freed slot on only {} of 250 access \ units — the reason `setup_surface` is a parameter no longer holds, and the \ - documentation that cites it needs re-measuring" + documentation that cites it needs re-measuring", + walk.inherited_a_just_freed_slot + ); + } + + /// The aliasing PRECONDITION, on both streams — the number that says the exemption + /// below is being tested by something rather than merely passing. + /// + /// Two conditions have to coincide inside ONE access unit for a conversion that + /// releases eagerly to hand the decode target a picture it is predicting from: the + /// access unit must remove a picture, and that picture must still be in the + /// `dpb_refs` snapshot the reference lists are built from. Low-delay H.264 is + /// exactly what makes them coincide, and NVENC seals it by writing + /// `max_num_ref_frames = 3` ALONGSIDE `max_dec_frame_buffering = 3` — a DPB exactly + /// as deep as its reference count — while `max_num_reorder_frames = 0` means the + /// evicted picture has already been output and is therefore evictable at all. + /// + /// The vendored conformance vector produces the shape ZERO times, which is why it + /// proved nothing on two other backends for two milestones. If that zero ever moves + /// the reasoning above is wrong and the 117 needs re-deriving before it means + /// anything. + #[test] + fn the_low_delay_stream_reassigns_slots_whose_pictures_it_still_reads() { + let vector = walk_for_aliasing(TEST_25FPS_H264); + assert_eq!(vector.converted, 250); + assert!( + vector.inherited_a_just_freed_slot > 0, + "no access unit of the vendored vector reused a freed slot, so the zeroes \ + below would be empty for a reason that has nothing to do with the hazard" ); assert_eq!( - collisions, 0, - "the decode target collided with a picture this access unit reads, on \ - {collisions} of 250 (first at AU {first:?})" + vector.removed_and_referenced, 0, + "the vendored vector is supposed to be BLIND to this shape" + ); + assert_eq!( + vector.setup_took_a_read_pictures_slot, 0, + "and therefore never to hand the setup picture a slot it still reads" + ); + + let lowdelay = walk_for_aliasing(LOWDELAY_640X480); + assert_eq!(lowdelay.converted, 120); + assert_eq!( + lowdelay.removed_and_referenced, 117, + "the low-delay stream must still exercise the aliasing precondition on \ + nearly every access unit — if this drops to zero the exemption below is no \ + longer being TESTED by anything, whatever else still passes" + ); + assert_eq!( + lowdelay.setup_took_a_read_pictures_slot, 117, + "and the slot really is handed straight back to the decode target: this is \ + the D3D11VA/Vulkan defect, present here, and harmless only because the \ + SURFACE does not follow the slot" + ); + } + + /// The exemption itself: no submission names its decode target as one of its own + /// references, on either stream. + /// + /// This conversion still releases its whole `removed` list inline, exactly as the + /// two backends that had to grow a `release_after_decode` deferral once did. It is + /// safe doing so for one reason, and it is a property of the INTERFACE rather than + /// of any stream: `plan_to_va` never invents a surface. Every reference it can name + /// is read out of the `surfaces` table it was handed, so a decode target that is + /// not in that table cannot be named, whatever the ledger does with slots. A slot + /// is not a surface here; on DXVA it was. + /// + /// ⚠ That makes this a statement about the CALLER's contract, so it is only half + /// the proof. The other half — that the Linux rung really does pick its decode + /// target from outside the table — cannot be made here, because the pool lives in + /// `pf-client-core`. It is + /// `video_vaapi_native`'s + /// `the_low_delay_stream_never_hands_the_decoder_a_surface_it_is_predicting_from`, + /// which drives this same stream through the real `Session`. + #[test] + fn no_submission_names_its_decode_target_as_one_of_its_own_references() { + for (name, walk) in [ + ("the vendored vector", walk_for_aliasing(TEST_25FPS_H264)), + ("the low-delay stream", walk_for_aliasing(LOWDELAY_640X480)), + ] { + assert_eq!( + walk.aliased, 0, + "{name}: {} of {} access units decode into a surface they predict from", + walk.aliased, walk.converted + ); + } + } + + /// A decode target the caller took from INSIDE the slot table is named as its own + /// reference — the counterfactual that gives the test above its teeth. + /// + /// Without this, `aliased == 0` would be consistent with a conversion that could + /// never alias for reasons of its own, and a reader could not tell which. This + /// picks the target the way the two broken backends effectively did — the surface + /// sitting in the slot the setup picture is about to take — and shows the same walk + /// then aliases on 117 of 120 access units of the low-delay stream. So the walk can + /// see the defect; it does not see it because the contract holds. + #[test] + fn taking_the_decode_target_from_the_slot_table_aliases_on_the_low_delay_stream() { + use pf_bitstream::h264::H264Planner; + + let mut planner = H264Planner::new(); + let mut slots: Option = None; + let mut table: Vec = Vec::new(); + let (mut converted, mut aliased) = (0usize, 0usize); + + for (index, au) in split_aus(LOWDELAY_640X480).into_iter().enumerate() { + let plan = planner.plan_au(au).expect("the low-delay stream plans"); + let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + table.resize(map.capacity(), VA_INVALID_SURFACE); + // The bug, modelled: convert first to learn the slot, then re-run the same + // access unit against the real ledger with the target read OUT of the + // table. Two passes only because the slot is not known until the conversion + // returns; the submission compared below is the second one. + // + // The probe's own `setup_surface` is arbitrary and deliberately so — the + // slot is chosen by `SlotMap::assign` from the ledger alone and no + // conversion consults the target to pick it, which is why one pass can + // stand in for the other. + let mut probe = map.clone(); + let peek = plan_to_va(&plan, au, &mut probe, &table, SURFACE_BASE) + .expect("the low-delay stream converts"); + let target = table[usize::from(peek.setup_slot)]; + let target = if target == VA_INVALID_SURFACE { + SURFACE_BASE + index as u32 + } else { + target + }; + let out = plan_to_va(&plan, au, map, &table, target).expect("the same conversion"); + table[usize::from(out.setup_slot)] = target; + + let names = |e: &VaPictureH264| { + e.flags & VA_PICTURE_H264_INVALID == 0 && e.picture_id == target + }; + if out.pic_params.reference_frames.iter().any(names) + || out + .slices + .iter() + .any(|s| s.ref_pic_list0.iter().any(names) || s.ref_pic_list1.iter().any(names)) + { + aliased += 1; + } + converted += 1; + } + + assert_eq!(converted, 120); + assert_eq!( + aliased, 117, + "binding the decode target BY SLOT is supposed to reproduce the defect on \ + this stream; if it no longer does, the exemption test above is passing for \ + a reason nobody has checked" ); } diff --git a/crates/pf-vaadec/src/va.rs b/crates/pf-vaadec/src/va.rs index 6de946ca..cdb250bd 100644 --- a/crates/pf-vaadec/src/va.rs +++ b/crates/pf-vaadec/src/va.rs @@ -1,4 +1,5 @@ -//! The libva decode buffer layouts for H.264, **hand-declared**. +//! The libva decode buffer layouts for H.264, **hand-declared** — plus the +//! codec-independent `VAImage` pair the test-only surface readback needs. //! //! There is no libva binding in this workspace and this crate deliberately does not //! introduce one: it must compile and be tested on macOS and in the Linux container, @@ -41,6 +42,24 @@ //! This crate never invents one: the conversion (`plan_to_va`) takes the caller's //! slot → `VASurfaceID` table and indexes it, so the Linux layer owns surface //! allocation and this half stays pure. +//! +//! # The image half, and why it is here at all +//! +//! [`VaImage`] and [`VaImageFormat`] are not decode buffers: they are what +//! `vaDeriveImage` (or `vaCreateImage` + `vaGetImage`) writes back when something +//! wants to READ a decoded surface on the CPU. Nothing on the production video path +//! does — the rung exports a DRM-PRIME dmabuf and the presenter samples it, which is +//! the zero-copy contract this project refuses to spend — so the only caller is the +//! frame-hash parity harness in `pf-client-core`'s `video_vaapi_native::parity`, which +//! exists solely under `#[cfg(test)]`. +//! +//! They live here for the same reason every other structure in this file does: the +//! harness must not force a `libva-dev` build dependency on a crate that compiles on +//! macOS and in the container. Declaring them costs nothing at runtime (nothing +//! constructs one outside a test) and lets the readback's geometry — the part that has +//! already cost this program a release, in the shape of a chroma plane read at the +//! DISPLAY height instead of the driver's reported offset — be unit-tested with no +//! device at all. That walk is [`pack_two_plane`]. /// `VA_INVALID_SURFACE` — what an unused `ReferenceFrames` / `RefPicList` entry /// carries. Paired with [`VA_PICTURE_H264_INVALID`]; drivers key on the flag, but a @@ -389,6 +408,346 @@ const _: () = { assert!(offset_of!(VaSliceParameterBufferH264, va_reserved) == 3112); }; +// --------------------------------------------------------------------------- +// The image pair — a CPU-readable view of a decoded surface (module docs). +// +// ⚠ TEST-ONLY BY CONSTRUCTION. Nothing on the production video path maps a surface; +// these types exist so a parity harness can, without this crate growing a libva +// build dependency. `pack_two_plane` below is pure and is the only logic here. +// --------------------------------------------------------------------------- + +/// `VA_LSB_FIRST` — the byte order every YUV format libva describes uses. Named +/// because [`VaImageFormat`] carries the field and a zero there is not a "left unset", +/// it is an invalid enumerator. +pub const VA_LSB_FIRST: u32 = 1; +/// `VA_MSB_FIRST` — measured beside it so the pair reads as an enumeration rather +/// than as one magic number. +pub const VA_MSB_FIRST: u32 = 2; + +/// `VAImageFormat` — what a `VAImage` is in, and what `vaCreateImage` is asked for. +/// +/// The RGB fields are dead weight for this crate's two formats (NV12 and P010) and +/// are declared anyway: they occupy bytes 12..32 and dropping them would shift +/// `va_reserved`, which is exactly the class of mistake the assertions below exist +/// to make a compile error. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct VaImageFormat { + pub fourcc: u32, + /// [`VA_LSB_FIRST`] or [`VA_MSB_FIRST`]. + pub byte_order: u32, + pub bits_per_pixel: u32, + /// RGB only. + pub depth: u32, + pub red_mask: u32, + pub green_mask: u32, + pub blue_mask: u32, + pub alpha_mask: u32, + /// `va_reserved[VA_PADDING_LOW]` — "must be zero". + pub va_reserved: [u32; 4], +} + +/// `VAImage` — the descriptor `vaDeriveImage` / `vaCreateImage` fills in. +/// +/// ⚠ `width` and `height` are **`unsigned short`**, not `unsigned int`. That is the +/// one thing about this structure a reader would get wrong by counting 32-bit words: +/// every field after them sits two bytes earlier than the obvious arithmetic puts it, +/// which is why `data_size` is at 60 and not 64. Measured, not reasoned about. +/// +/// `pitches` and `offsets` are per PLANE and are the driver's own — the chroma plane +/// begins at `offsets[1]`, which on a decode surface is nowhere near +/// `pitches[0] * display_height` because the surface is padded to the codec's granule. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VaImage { + /// `VAImageID`, and what `vaGetImage` / `vaDestroyImage` are handed. + pub image_id: u32, + pub format: VaImageFormat, + /// `VABufferID` — the buffer `vaMapBuffer` returns the pixels of. + pub buf: u32, + pub width: u16, + pub height: u16, + /// The whole mapped extent, in bytes. Everything [`pack_two_plane`] reads is + /// bounds-checked against it. + pub data_size: u32, + pub num_planes: u32, + pub pitches: [u32; 3], + pub offsets: [u32; 3], + /// Palette fields, meaningless for YUV and declared for their bytes. + pub num_palette_entries: i32, + pub entry_bytes: i32, + pub component_order: [i8; 4], + /// `va_reserved[VA_PADDING_LOW]`. + pub va_reserved: [u32; 4], +} + +impl VaImage { + /// An all-zero descriptor — what a caller hands `vaDeriveImage` to fill. + /// + /// Zero rather than uninitialised on purpose: a failed derive leaves a descriptor + /// the caller still reads — to decide whether there is an image to destroy, and to + /// report what the driver DID hand back — and reading uninitialised bytes to do + /// that is undefined behaviour rather than a diagnostic. + pub const fn zeroed() -> VaImage { + VaImage { + image_id: 0, + format: VaImageFormat { + fourcc: 0, + byte_order: 0, + bits_per_pixel: 0, + depth: 0, + red_mask: 0, + green_mask: 0, + blue_mask: 0, + alpha_mask: 0, + va_reserved: [0; 4], + }, + buf: 0, + width: 0, + height: 0, + data_size: 0, + num_planes: 0, + pitches: [0; 3], + offsets: [0; 3], + num_palette_entries: 0, + entry_bytes: 0, + component_order: [0; 4], + va_reserved: [0; 4], + } + } +} + +/// Why a mapped image could not be read as the picture it was supposed to hold. +/// +/// Every arm carries what the DRIVER said rather than a verdict, because the whole +/// point of this walk refusing instead of guessing is that the refusal names the +/// thing that has to be looked at next. A harness that quietly produced a short or +/// mis-strided buffer would compare hashes of garbage against libavcodec's and report +/// a decode defect that is not there. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageReadError { + /// The caller asked for a format this walk does not describe. Only the two + /// two-plane YUV formats the surface pool is ever created with are supported. + UnsupportedFourcc { fourcc: u32 }, + /// The image came back in a different format from the surface pool's — a driver + /// that substituted, which is precisely the "derive handed you something you + /// cannot interpret" case. + Fourcc { got: u32, want: u32 }, + /// Fewer than two planes: a packed or opaque layout, not NV12/P010. + NotTwoPlane { planes: u32 }, + /// The image is smaller than the region asked for. + TooSmall { + image: (u32, u32), + display: (u32, u32), + }, + /// A row of the picture does not fit the plane's own pitch. + Pitch { + plane: usize, + pitch: u32, + need: usize, + }, + /// A row would be read past the end of the mapped buffer. + OutOfBounds { + plane: usize, + row: u32, + at: usize, + end: usize, + mapped: usize, + }, +} + +impl std::fmt::Display for ImageReadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ImageReadError::UnsupportedFourcc { fourcc } => { + write!(f, "no two-plane layout for fourcc {}", fourcc_name(*fourcc)) + } + ImageReadError::Fourcc { got, want } => write!( + f, + "the image is {} but the surface pool is {}", + fourcc_name(*got), + fourcc_name(*want) + ), + ImageReadError::NotTwoPlane { planes } => { + write!(f, "the image has {planes} plane(s), not two") + } + ImageReadError::TooSmall { image, display } => write!( + f, + "the image is {}x{} but the picture is {}x{}", + image.0, image.1, display.0, display.1 + ), + ImageReadError::Pitch { plane, pitch, need } => write!( + f, + "plane {plane}'s pitch is {pitch} bytes, a row needs {need}" + ), + ImageReadError::OutOfBounds { + plane, + row, + at, + end, + mapped, + } => write!( + f, + "plane {plane} row {row} spans {at}..{end} of a {mapped}-byte mapping" + ), + } + } +} + +impl std::error::Error for ImageReadError {} + +/// A fourcc as its four characters, for a message a human can act on. +fn fourcc_name(fourcc: u32) -> String { + let bytes = fourcc.to_le_bytes(); + match std::str::from_utf8(&bytes) { + Ok(s) if bytes.iter().all(|b| b.is_ascii_graphic()) => s.to_string(), + _ => format!("{fourcc:#010x}"), + } +} + +/// How many bytes one tightly packed `display`-sized picture of `fourcc` occupies — +/// the layout every golden set in this program hashes. +/// +/// `None` for a fourcc with no two-plane 4:2:0 layout here. +pub fn packed_len(display: (u32, u32), fourcc: u32) -> Option { + let bytes_per_sample = bytes_per_sample(fourcc)?; + let (w, h) = (display.0 as usize, display.1 as usize); + Some(w * bytes_per_sample * (h + h.div_ceil(2))) +} + +/// One luma sample's size in bytes for the two formats the pool is ever built with. +fn bytes_per_sample(fourcc: u32) -> Option { + match fourcc { + crate::drm::VA_FOURCC_NV12 => Some(1), + // ⚠ P010 is 16 bits per sample with the ten meaningful bits in the HIGH end + // of each little-endian word. This walk moves bytes and never touches the + // alignment; a driver that handed back LSB-aligned samples would produce a + // buffer of exactly the right SIZE and the wrong content, which is a + // divergence the goldens catch and this function cannot. + crate::drm::VA_FOURCC_P010 => Some(2), + _ => None, + } +} + +/// Read the `display`-sized picture out of a mapped `VAImage`, packed tightly — byte +/// for byte the layout `pf-vkdecode`'s golden files hash. +/// +/// This is the whole of the readback that can be wrong without a device, so it is the +/// whole of what is worth testing without one. Three things it does deliberately: +/// +/// * **The chroma plane starts at `offsets[1]`**, the driver's own number, never at +/// `pitches[0] * height`. A decode surface is padded to the codec's granule — 240 +/// lines of HEVC live in a 256-line surface — so computing the offset from the +/// display height reads the tail of the luma padding as chroma and smears every +/// row. This project has already paid for that once on another rung. +/// * **Padding columns are dropped per row.** `pitches[0]` is the surface's stride, +/// which is wider than the picture; only `width * bytes_per_sample` bytes of each +/// row belong to the golden. +/// * **Every read is bounds-checked against the mapping the driver declared**, and a +/// failure is returned rather than clamped. A short mapping means the descriptor +/// and the buffer disagree, and no hash taken from it means anything. +/// +/// `mapped` must be the buffer `vaMapBuffer` returned, of length +/// [`VaImage::data_size`]; the caller passes it as a slice so this function needs no +/// `unsafe` and can be driven from a plain array in a test. +pub fn pack_two_plane( + image: &VaImage, + mapped: &[u8], + display: (u32, u32), + fourcc: u32, +) -> Result, ImageReadError> { + let bytes_per_sample = + bytes_per_sample(fourcc).ok_or(ImageReadError::UnsupportedFourcc { fourcc })?; + if image.format.fourcc != fourcc { + return Err(ImageReadError::Fourcc { + got: image.format.fourcc, + want: fourcc, + }); + } + if image.num_planes < 2 { + return Err(ImageReadError::NotTwoPlane { + planes: image.num_planes, + }); + } + let (width, height) = display; + if u32::from(image.width) < width || u32::from(image.height) < height { + return Err(ImageReadError::TooSmall { + image: (u32::from(image.width), u32::from(image.height)), + display, + }); + } + // One row of the picture, in both planes: 4:2:0 chroma is half the rows but + // interleaved (U,V) pairs, so a chroma row carries exactly as many BYTES as a + // luma row. + let row_bytes = width as usize * bytes_per_sample; + let rows = [height, height.div_ceil(2)]; + let mut out = Vec::with_capacity(row_bytes * (rows[0] + rows[1]) as usize); + for (plane, plane_rows) in rows.iter().enumerate() { + let pitch = image.pitches[plane] as usize; + if pitch < row_bytes { + return Err(ImageReadError::Pitch { + plane, + pitch: image.pitches[plane], + need: row_bytes, + }); + } + let base = image.offsets[plane] as usize; + for row in 0..*plane_rows { + let at = base + row as usize * pitch; + let end = at + row_bytes; + if end > mapped.len() { + return Err(ImageReadError::OutOfBounds { + plane, + row, + at, + end, + mapped: mapped.len(), + }); + } + out.extend_from_slice(&mapped[at..end]); + } + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Image layout proofs — the probe's output, pinned (libva 2.23.0-1ubuntu1, +// x86_64-linux-gnu, measured 2026-08-07 by `layout-probe.c`). +// --------------------------------------------------------------------------- + +const _: () = { + use std::mem::offset_of; + use std::mem::size_of; + + assert!(size_of::() == 48); + assert!(offset_of!(VaImageFormat, fourcc) == 0); + assert!(offset_of!(VaImageFormat, byte_order) == 4); + assert!(offset_of!(VaImageFormat, bits_per_pixel) == 8); + assert!(offset_of!(VaImageFormat, depth) == 12); + assert!(offset_of!(VaImageFormat, red_mask) == 16); + assert!(offset_of!(VaImageFormat, green_mask) == 20); + assert!(offset_of!(VaImageFormat, blue_mask) == 24); + assert!(offset_of!(VaImageFormat, alpha_mask) == 28); + assert!(offset_of!(VaImageFormat, va_reserved) == 32); + + // ⚠ `width`/`height` are 16-bit, which is why `data_size` is at 60 rather than + // at the 64 that counting 32-bit fields would give. + assert!(size_of::() == 120); + assert!(offset_of!(VaImage, image_id) == 0); + assert!(offset_of!(VaImage, format) == 4); + assert!(offset_of!(VaImage, buf) == 52); + assert!(offset_of!(VaImage, width) == 56); + assert!(offset_of!(VaImage, height) == 58); + assert!(offset_of!(VaImage, data_size) == 60); + assert!(offset_of!(VaImage, num_planes) == 64); + assert!(offset_of!(VaImage, pitches) == 68); + assert!(offset_of!(VaImage, offsets) == 80); + assert!(offset_of!(VaImage, num_palette_entries) == 92); + assert!(offset_of!(VaImage, entry_bytes) == 96); + assert!(offset_of!(VaImage, component_order) == 100); + assert!(offset_of!(VaImage, va_reserved) == 104); +}; + #[cfg(test)] mod tests { use super::*; @@ -585,4 +944,241 @@ mod tests { .all(|e| e.picture_id == VA_INVALID_SURFACE)); assert_eq!(s.slice_data_flag, VA_SLICE_DATA_FLAG_ALL); } + + // ----------------------------------------------------------------------- + // The image walk. Every one of these runs on macOS and in the container: the + // geometry is the half of a surface readback that can be wrong without a + // device, and it is the half that has been wrong before. + // ----------------------------------------------------------------------- + + /// A driver-shaped `VAImage`: a surface PADDED past the picture in both axes, + /// with the chroma plane where the driver puts it rather than where the display + /// height would. + // `_picture` is named at every call site so each test reads as the shape it is + // about, and is deliberately not consulted: the walk takes the picture size from + // its own argument, which is the whole point of the crop. + fn padded_image( + _picture: (u16, u16), + surface: (u16, u16), + pitch: u32, + fourcc: u32, + ) -> (VaImage, Vec) { + let mut image = VaImage::zeroed(); + image.format.fourcc = fourcc; + image.format.byte_order = VA_LSB_FIRST; + image.width = surface.0; + image.height = surface.1; + image.num_planes = 2; + image.pitches = [pitch, pitch, 0]; + // The trap, expressed: chroma starts after the WHOLE padded luma plane. + image.offsets = [0, pitch * u32::from(surface.1), 0]; + let total = pitch as usize * (surface.1 as usize + surface.1.div_ceil(2) as usize); + image.data_size = total as u32; + // Fill the mapping so every byte says where it came from: luma rows count + // 0.., chroma rows 128.., and the padding columns are 0xff so a walk that + // read them would produce something unmistakable. + let mut mapped = vec![0xffu8; total]; + for y in 0..surface.1 as usize { + for x in 0..pitch as usize { + mapped[y * pitch as usize + x] = if x < surface.0 as usize { + (y % 100) as u8 + } else { + 0xff + }; + } + } + let chroma = image.offsets[1] as usize; + for y in 0..surface.1.div_ceil(2) as usize { + for x in 0..pitch as usize { + mapped[chroma + y * pitch as usize + x] = if x < surface.0 as usize { + 128 + (y % 100) as u8 + } else { + 0xff + }; + } + } + (image, mapped) + } + + #[test] + fn the_walk_crops_to_the_picture_and_takes_chroma_from_the_drivers_offset() { + // 320x240 picture in a 320x256 surface at a 384-byte pitch — HEVC's 128-line + // granule and a stride that is not the width, which is the everyday shape. + let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + let out = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12) + .expect("the walk must read a padded NV12 surface"); + assert_eq!(out.len(), 320 * 240 + 320 * 120); + assert_eq!( + out.len(), + packed_len((320, 240), crate::drm::VA_FOURCC_NV12).unwrap() + ); + // No padding byte reached the output: 0xff is only ever a padding column. + assert!( + !out.contains(&0xff), + "a padding column leaked into the packed picture" + ); + // Luma row 3 is all 3s; chroma row 3 is all 131 — which is only true if the + // chroma plane was taken from offsets[1] and not from pitch * 240. + assert!(out[3 * 320..4 * 320].iter().all(|&b| b == 3)); + let chroma = 320 * 240; + assert!(out[chroma + 3 * 320..chroma + 4 * 320] + .iter() + .all(|&b| b == 131)); + } + + #[test] + fn reading_chroma_at_the_display_height_would_have_been_caught() { + // The counterfactual for the assertion above: an image that claims chroma + // starts at `pitch * display_height` — the 1088-row smear — hands back + // LUMA padding rows where chroma belongs, and the walk cannot tell. So the + // guarantee is that the walk uses the DRIVER's offset, and this proves the + // two answers actually differ on the shape the drivers hand out (they would + // coincide on an unpadded surface, which is why the test above uses one that + // is padded in BOTH axes). + let (mut image, mapped) = + padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + let right = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12) + .expect("the driver's own offset reads"); + image.offsets[1] = 384 * 240; + let wrong = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12) + .expect("the wrong offset also reads — that is the point"); + assert_ne!( + right, wrong, + "chroma at the display height must differ from chroma at the driver's \ + offset, or this walk's central claim is untestable" + ); + } + + #[test] + fn ten_bit_rows_are_twice_as_wide() { + // P010's samples are 16 bits, so a 320-sample row is 640 bytes and the packed + // picture is exactly twice an NV12 one. A walk that assumed one byte per + // sample would produce a half-width picture of the right total length for + // some other resolution, which is the kind of thing a length check alone + // misses. + let (image, mapped) = padded_image((320, 240), (320, 256), 768, crate::drm::VA_FOURCC_P010); + let out = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_P010) + .expect("the walk must read a padded P010 surface"); + assert_eq!(out.len(), 320 * 2 * 240 + 320 * 2 * 120); + assert_eq!( + out.len(), + packed_len((320, 240), crate::drm::VA_FOURCC_P010).unwrap() + ); + assert_eq!( + out.len(), + 2 * packed_len((320, 240), crate::drm::VA_FOURCC_NV12).unwrap() + ); + } + + #[test] + fn an_odd_height_keeps_its_half_chroma_row() { + let (image, mapped) = padded_image((16, 9), (16, 16), 32, crate::drm::VA_FOURCC_NV12); + let out = pack_two_plane(&image, &mapped, (16, 9), crate::drm::VA_FOURCC_NV12) + .expect("an odd height still reads"); + assert_eq!(out.len(), 16 * 9 + 16 * 5, "9 luma rows, 5 chroma rows"); + } + + #[test] + fn a_substituted_format_is_refused_rather_than_reinterpreted() { + let (mut image, mapped) = + padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + image.format.fourcc = crate::drm::VA_FOURCC_P010; + assert_eq!( + pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12), + Err(ImageReadError::Fourcc { + got: crate::drm::VA_FOURCC_P010, + want: crate::drm::VA_FOURCC_NV12 + }) + ); + } + + #[test] + fn a_packed_or_opaque_image_is_refused() { + let (mut image, mapped) = + padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + image.num_planes = 1; + assert_eq!( + pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12), + Err(ImageReadError::NotTwoPlane { planes: 1 }) + ); + } + + #[test] + fn an_image_smaller_than_the_picture_is_refused() { + let (image, mapped) = padded_image((320, 240), (320, 240), 384, crate::drm::VA_FOURCC_NV12); + assert_eq!( + pack_two_plane(&image, &mapped, (321, 240), crate::drm::VA_FOURCC_NV12), + Err(ImageReadError::TooSmall { + image: (320, 240), + display: (321, 240) + }) + ); + } + + #[test] + fn a_pitch_narrower_than_a_row_is_refused() { + let (mut image, mapped) = + padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + image.pitches[1] = 16; + assert_eq!( + pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12), + Err(ImageReadError::Pitch { + plane: 1, + pitch: 16, + need: 320 + }) + ); + } + + #[test] + fn a_mapping_shorter_than_the_descriptor_claims_is_refused_not_truncated() { + // The failure mode that matters most: a short read must NOT silently produce + // a shorter picture, because its hash would then be a hash of something the + // decoder never wrote. + let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + // One byte short of the LAST chroma row the picture needs. Cutting the tail + // of the allocation would not do it: the surface is padded past the picture, + // so there is slack after the last row this walk reads — which is itself + // worth pinning, since it is why a `data_size` check alone would not catch a + // driver whose offsets point outside its buffer. + let last_row_end = image.offsets[1] as usize + 119 * image.pitches[1] as usize + 320; + assert!( + last_row_end < mapped.len(), + "the padded surface must have slack after the picture's last chroma row" + ); + let err = pack_two_plane( + &image, + &mapped[..last_row_end - 1], + (320, 240), + crate::drm::VA_FOURCC_NV12, + ) + .expect_err("a short mapping must be refused"); + assert!( + matches!( + err, + ImageReadError::OutOfBounds { + plane: 1, + row: 119, + .. + } + ), + "expected the last chroma row to be refused, got {err}" + ); + } + + #[test] + fn an_unknown_fourcc_has_no_packed_length_and_no_walk() { + assert_eq!( + packed_len((320, 240), 0x3132_3449), + None, + "I421 is not ours" + ); + let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12); + assert_eq!( + pack_two_plane(&image, &mapped, (320, 240), 0x3132_3449), + Err(ImageReadError::UnsupportedFourcc { + fourcc: 0x3132_3449 + }) + ); + } } diff --git a/crates/pf-vkdecode/src/decoder.rs b/crates/pf-vkdecode/src/decoder.rs index ac417aa5..d5fafc0b 100644 --- a/crates/pf-vkdecode/src/decoder.rs +++ b/crates/pf-vkdecode/src/decoder.rs @@ -822,175 +822,204 @@ impl VkH264Decoder { } let vk_plan = vk_plan.expect("the rebuilt session matches its own plan"); - let state = self.state.as_mut().expect("ensured above"); - // The per-AU active-reference gate: the session was created with - // maxActiveReferencePictures; binding more in one decode op would be a - // silent VUID violation on the drivers that matter most. - let max_active = state.session.config.max_active_references as usize; - if vk_plan.refs.len() > max_active { - return Err(VkDecodeError::Unsupported(format!( - "AU references {} pictures, session allows {max_active} active references", - vk_plan.refs.len() - ))); - } - - // Coincide binding sync: slots the planner released no longer bind their - // images (the pictures may still be pending/held — untouched), and the - // setup slot's PREVIOUS binding is cleared before it binds fresh. - let setup = usize::from(vk_plan.setup_slot); - if state.dpb.is_none() { - let mut held = vec![false; state.slot_image.len()]; - for (slot, _id) in state.slots.held() { - held[usize::from(slot)] = true; + // Everything from here to the deferred release below is ONE unit of ledger + // work. `plan_to_vk` has already committed this AU's setup assignment and + // handed back the removals it deliberately did NOT apply + // (`DecodePlanVk::release_after_decode`); until those are applied the slot + // map holds one picture too many. A `?` anywhere in the region would skip + // them and leak a slot per failed AU — four `?`s and three early `return`s + // could — so the region's Result is HELD and the release runs either way. + let submitted = (|| -> Result<(), VkDecodeError> { + let state = self.state.as_mut().expect("ensured above"); + // The per-AU active-reference gate: the session was created with + // maxActiveReferencePictures; binding more in one decode op would be a + // silent VUID violation on the drivers that matter most. + let max_active = state.session.config.max_active_references as usize; + if vk_plan.refs.len() > max_active { + return Err(VkDecodeError::Unsupported(format!( + "AU references {} pictures, session allows {max_active} active references", + vk_plan.refs.len() + ))); } - for (slot, binding) in state.slot_image.iter_mut().enumerate() { - if let Some(picture) = *binding { - if !held[slot] || slot == setup { - state.pool.pictures[picture].bound = false; - *binding = None; + + // Coincide binding sync: slots the planner released no longer bind their + // images (the pictures may still be pending/held — untouched), and the + // setup slot's PREVIOUS binding is cleared before it binds fresh. + let setup = usize::from(vk_plan.setup_slot); + if state.dpb.is_none() { + let mut held = vec![false; state.slot_image.len()]; + for (slot, _id) in state.slots.held() { + held[usize::from(slot)] = true; + } + for (slot, binding) in state.slot_image.iter_mut().enumerate() { + if let Some(picture) = *binding { + if !held[slot] || slot == setup { + state.pool.pictures[picture].bound = false; + *binding = None; + } } } } - } - // The decode target: a FREE pool image (never one a consumer holds — the - // whole point of the pool model). Exhaustion means the consumer owes - // more than HOLD_HEADROOM releases; no wait can free an image here. - let Some(dst) = state.pool.free_index() else { - debug!( - held = state.pool.held_total(), - "picture pool exhausted — release_frame owed" - ); - return Err(VkDecodeError::NoFreeSlot); - }; + // The decode target: a FREE pool image (never one a consumer holds — the + // whole point of the pool model). Exhaustion means the consumer owes + // more than HOLD_HEADROOM releases; no wait can free an image here. + let Some(dst) = state.pool.free_index() else { + debug!( + held = state.pool.held_total(), + "picture pool exhausted — release_frame owed" + ); + return Err(VkDecodeError::NoFreeSlot); + }; - // Cross-queue waits (the AVVkFrame contract): the dst image's last known - // timeline value (covers a presenter write-back after release), plus — - // coincide mode — every referenced image's value, so reference reads - // order after any presenter layout restore already reported back. - let mut waits: Vec<(vk::Semaphore, u64)> = Vec::new(); - { - let dst_pic = &state.pool.pictures[dst]; - if dst_pic.value > 0 { - waits.push((dst_pic.semaphore, dst_pic.value)); + // Cross-queue waits (the AVVkFrame contract): the dst image's last known + // timeline value (covers a presenter write-back after release), plus — + // coincide mode — every referenced image's value, so reference reads + // order after any presenter layout restore already reported back. + let mut waits: Vec<(vk::Semaphore, u64)> = Vec::new(); + { + let dst_pic = &state.pool.pictures[dst]; + if dst_pic.value > 0 { + waits.push((dst_pic.semaphore, dst_pic.value)); + } } - } - if state.dpb.is_none() { - for r in &vk_plan.refs { - if let Some(picture) = state.slot_image[usize::from(r.slot)] { - let pic = &state.pool.pictures[picture]; - if pic.value > 0 && !waits.iter().any(|(sem, _)| *sem == pic.semaphore) { - waits.push((pic.semaphore, pic.value)); + if state.dpb.is_none() { + for r in &vk_plan.refs { + if let Some(picture) = state.slot_image[usize::from(r.slot)] { + let pic = &state.pool.pictures[picture]; + if pic.value > 0 && !waits.iter().any(|(sem, _)| *sem == pic.semaphore) { + waits.push((pic.semaphore, pic.value)); + } } } } - } - let signal_value = state.pool.pictures[dst].value + 1; + let signal_value = state.pool.pictures[dst].value + 1; - // Command buffer + query slot for this submission. - let submission = state.submitted; - let cmd_index = (submission % state.ops.cmds.len() as u64) as usize; - if let Some((sem, value)) = state.cmd_marks[cmd_index] { - // SAFETY: live device; the token is a pool image's semaphore. - unsafe { wait_timeline(self.dev.ash(), sem, value, "command buffer reuse")? }; - } - let query_index = (submission % u64::from(state.ops.query_count)) as u32; + // Command buffer + query slot for this submission. + let submission = state.submitted; + let cmd_index = (submission % state.ops.cmds.len() as u64) as usize; + if let Some((sem, value)) = state.cmd_marks[cmd_index] { + // SAFETY: live device; the token is a pool image's semaphore. + unsafe { wait_timeline(self.dev.ash(), sem, value, "command buffer reuse")? }; + } + let query_index = (submission % u64::from(state.ops.query_count)) as u32; - // Upload the AU (recycles/grows against submission-completion tokens). - let device = self.dev.ash().clone(); - let mut poll = |token: &(vk::Semaphore, u64)| -> Result { - // SAFETY: live device; the token's semaphore is a pool semaphore. - let current = unsafe { device.get_semaphore_counter_value(token.0) } - .map_err(VkDecodeError::from)?; - Ok(current >= token.1) - }; - let device2 = self.dev.ash().clone(); - let mut wait = |token: &(vk::Semaphore, u64)| -> Result<(), VkDecodeError> { - // SAFETY: as above. - unsafe { wait_timeline(&device2, token.0, token.1, "bitstream slot drain") } - }; - // The bitstream buffer carries the SLICE NALUs only, concatenated — a - // real AU opens with AUD/SEI (and, at IDRs, SPS/PPS) NALUs, and feeding - // those to the VCN firmware inside the decode range HANGS it (the .25 - // `vcn_unified_0 ring timeout`; FFmpeg feeds slices-only for the same - // reason). `pack_slices` rebases the offsets into the packed buffer AND - // normalises each slice's Annex-B prefix to three bytes — the two go - // together by construction, see `crate::ring::three_byte_prefix`. - let plan_segments: Vec> = - plan.slices.iter().map(|s| s.data.clone()).collect(); - let Some(packed) = pack_slices(au, &plan_segments) else { - return Err(VkDecodeError::Unsupported( - "packed slice data exceeds the u32 offsets Vulkan submits".into(), - )); - }; - let slice_offsets = packed.offsets; - // SAFETY: live device; the segments are the plan's own in-bounds slice - // ranges (narrowed by the prefix normalisation, so still in bounds); every - // pending token is the completion signal of the submission that consumed - // the slot. - let upload = unsafe { + // Upload the AU (recycles/grows against submission-completion tokens). + let device = self.dev.ash().clone(); + let mut poll = |token: &(vk::Semaphore, u64)| -> Result { + // SAFETY: live device; the token's semaphore is a pool semaphore. + let current = unsafe { device.get_semaphore_counter_value(token.0) } + .map_err(VkDecodeError::from)?; + Ok(current >= token.1) + }; + let device2 = self.dev.ash().clone(); + let mut wait = |token: &(vk::Semaphore, u64)| -> Result<(), VkDecodeError> { + // SAFETY: as above. + unsafe { wait_timeline(&device2, token.0, token.1, "bitstream slot drain") } + }; + // The bitstream buffer carries the SLICE NALUs only, concatenated — a + // real AU opens with AUD/SEI (and, at IDRs, SPS/PPS) NALUs, and feeding + // those to the VCN firmware inside the decode range HANGS it (the .25 + // `vcn_unified_0 ring timeout`; FFmpeg feeds slices-only for the same + // reason). `pack_slices` rebases the offsets into the packed buffer AND + // normalises each slice's Annex-B prefix to three bytes — the two go + // together by construction, see `crate::ring::three_byte_prefix`. + let plan_segments: Vec> = + plan.slices.iter().map(|s| s.data.clone()).collect(); + let Some(packed) = pack_slices(au, &plan_segments) else { + return Err(VkDecodeError::Unsupported( + "packed slice data exceeds the u32 offsets Vulkan submits".into(), + )); + }; + let slice_offsets = packed.offsets; + // SAFETY: live device; the segments are the plan's own in-bounds slice + // ranges (narrowed by the prefix normalisation, so still in bounds); every + // pending token is the completion signal of the submission that consumed + // the slot. + let upload = unsafe { + state + .ring + .upload(&self.dev, au, &packed.segments, &mut poll, &mut wait)? + }; + + // Record + submit, signalling the dst image's next timeline value. + // SAFETY: live device; every handle recorded below belongs to this + // session generation, and the packed slices sit uploaded in the ring slot. + unsafe { + record_and_submit( + &self.dev, + &*self.lock, + state, + &vk_plan, + &slice_offsets, + &upload, + dst, + cmd_index, + query_index, + &waits, + signal_value, + )?; + } + + // Post-submit bookkeeping. + let dst_sem = state.pool.pictures[dst].semaphore; + state.pool.pictures[dst].value = signal_value; + state.pool.pictures[dst].pending = true; + if state.dpb.is_none() { + state.pool.pictures[dst].bound = true; + state.slot_image[setup] = Some(dst); + } + state.cmd_marks[cmd_index] = Some((dst_sem, signal_value)); + state.query_marks[query_index as usize] = submission; + state.submitted += 1; + state.last_submit = Some((dst_sem, signal_value)); state .ring - .upload(&self.dev, au, &packed.segments, &mut poll, &mut wait)? - }; + .pending + .set_pending(upload.slot, (dst_sem, signal_value)); - // Record + submit, signalling the dst image's next timeline value. - // SAFETY: live device; every handle recorded below belongs to this - // session generation, and the packed slices sit uploaded in the ring slot. - unsafe { - record_and_submit( - &self.dev, - &*self.lock, - state, - &vk_plan, - &slice_offsets, - &upload, - dst, - cmd_index, - query_index, - &waits, - signal_value, - )?; + // Refresh the per-slot reference cache from this AU's facts. + state.slot_refs[setup] = Some(vk_plan.setup_ref); + for r in &vk_plan.refs { + state.slot_refs[usize::from(r.slot)] = Some(r.std); + } + + self.pending.insert( + vk_plan.setup_id, + PendingPic { + image: dst, + submission, + query_slot: query_index, + timeline_value: signal_value, + crop: plan.picture.display_crop, + colour: plan.picture.colour, + poc: plan.picture.pic_order_cnt, + is_idr: plan.picture.is_idr, + recovery, + decode_order, + }, + ); + Ok(()) + })(); + + // The slots this AU's own 8.2.5 marking retired while the decode op still + // BOUND them (`DecodePlanVk::release_after_decode`). Held through the + // conversion, the coincide binding sync and the submission, so none of the + // three could take them; freed now that the op is recorded, so the next AU + // may have them. Their images stay pinned by `bound` until that AU's sync, + // the same one-frame grace every other released slot's image gets. + // + // This runs on the failure paths too, and must: the removals are the + // planner's verdict on pictures that left the DPB, which nothing this AU + // does can undo. + if let Some(state) = self.state.as_mut() { + for &id in &vk_plan.release_after_decode { + if !state.slots.release(id) { + trace!(id, "deferred release of an id the slot map no longer holds"); + } + } } - - // Post-submit bookkeeping. - let dst_sem = state.pool.pictures[dst].semaphore; - state.pool.pictures[dst].value = signal_value; - state.pool.pictures[dst].pending = true; - if state.dpb.is_none() { - state.pool.pictures[dst].bound = true; - state.slot_image[setup] = Some(dst); - } - state.cmd_marks[cmd_index] = Some((dst_sem, signal_value)); - state.query_marks[query_index as usize] = submission; - state.submitted += 1; - state.last_submit = Some((dst_sem, signal_value)); - state - .ring - .pending - .set_pending(upload.slot, (dst_sem, signal_value)); - - // Refresh the per-slot reference cache from this AU's facts. - state.slot_refs[setup] = Some(vk_plan.setup_ref); - for r in &vk_plan.refs { - state.slot_refs[usize::from(r.slot)] = Some(r.std); - } - - self.pending.insert( - vk_plan.setup_id, - PendingPic { - image: dst, - submission, - query_slot: query_index, - timeline_value: signal_value, - crop: plan.picture.display_crop, - colour: plan.picture.colour, - poc: plan.picture.pic_order_cnt, - is_idr: plan.picture.is_idr, - recovery, - decode_order, - }, - ); + submitted?; // The plan's DPB verdicts over the pending map: outputs become ready // frames (their images move pending → held until released); diff --git a/crates/pf-vkdecode/src/integrity.rs b/crates/pf-vkdecode/src/integrity.rs index 35892e85..a5f493a3 100644 --- a/crates/pf-vkdecode/src/integrity.rs +++ b/crates/pf-vkdecode/src/integrity.rs @@ -32,6 +32,12 @@ use crate::{Av1PlanWarning, H265PlanWarning, PlanWarning}; /// full (the plan holds the pre-rebase 8.2.1 values; later AUs reference the /// rebased ones). /// +/// `LevelDerivedDpb` does not either: the picture is intact and fully planned. It +/// reports that the SPS never declared its DPB depth, so the plan had to size from +/// A.3.1's level ceiling and the result will not fit a mainstream slot pool — a +/// property of the STREAM's signalling, which the decoder answers by failing to open +/// a session, not by showing a damaged frame. +/// /// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or /// a `_ => false`) makes "damage" the opt-in and silence the default, so a /// `PlanWarning` added later — by definition one nobody here has classified — @@ -43,7 +49,7 @@ pub fn is_integrity_warning(w: &PlanWarning) -> bool { PlanWarning::FrameNumGap { .. } | PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. } => true, - PlanWarning::Mmco5Rebase => false, + PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false, } } diff --git a/crates/pf-vkdecode/src/pic.rs b/crates/pf-vkdecode/src/pic.rs index 2e7c0eda..df8aa772 100644 --- a/crates/pf-vkdecode/src/pic.rs +++ b/crates/pf-vkdecode/src/pic.rs @@ -12,7 +12,6 @@ use ash::vk::native as hh; use pf_bitstream::h264::AuPlan; use pf_bitstream::h264::PicId; use pf_bitstream::h264::RefPic; -use tracing::trace; use crate::slots::SlotError; use crate::slots::SlotMap; @@ -56,6 +55,35 @@ pub struct DecodePlanVk { pub setup_is_reference: bool, /// The unique referenced pictures across all slices, in first-appearance order. pub refs: Vec, + /// Slots this access unit's own end-of-picture bookkeeping retires while the + /// decode op still BINDS them. Release them once that op is recorded — never + /// inside the conversion, and never dropped. + /// + /// The H.264 twin of [`crate::pic_av1::DecodePlanVkAv1::release_after_decode`], + /// and it exists for exactly the same reason: [`SlotMap::assign`] takes the lowest + /// free slot, so a release here hands the setup assignment two lines later the + /// slot a reference of this very AU still occupies. `pf_bitstream`'s `H264Planner` + /// snapshots `dpb_refs` in `begin_picture`, BEFORE `finish_picture` runs 8.2.5's + /// marking and C.4.5.3's bump, so a picture the sliding window unmarks and the + /// bump evicts is in both `dpb_refs` and `dpb.removed` for one AU — which needs + /// the eviction to be of an already-OUTPUT picture, i.e. low-delay H.264. + /// + /// **Measured 2026-08-07 on this rung as well as the DXVA one:** 297 of 300 AUs of + /// every stream a punktfunk host emits, at 720p, 1080p and 2160p alike. See + /// [`pf_dxvadec::pic::DecodePlanDxva::release_after_decode`]'s docs for the full + /// measurement and for why `test-25fps.h264` measures zero. + /// + /// Both of this rung's DPB modes break on it, differently and neither loudly: + /// in DISTINCT mode `slot_view` hands the aliased reference the same DPB array + /// layer the setup writes, a read-write alias of one subresource; in COINCIDE mode + /// the binding sync clears `slot_image[setup]` (it is the setup slot now) and the + /// reference resolves to no bound image at all, dropping out of `pReferenceSlots` + /// with a `trace!` and nothing else. + /// + /// The caller must apply these on its FAILURE paths too. They are slot-ledger + /// bookkeeping the planner already committed, not something this AU's fate can + /// undo — dropping them leaks a slot per AU and reaches `SlotError::Full`. + pub release_after_decode: Vec, } /// Conversion failures. Stream damage never lands here — pf-bitstream degrades it to @@ -162,11 +190,14 @@ fn ref_info(rp: &RefPic) -> hh::StdVideoDecodeH264ReferenceInfo { /// reference, e.g. the sliding window dropping the oldest short-term reference, /// so `removed` must not be applied before the lists are mapped; /// 3. slice offsets are validated (read-only); -/// 4. `removed` is applied — removals were real regardless of this AU's fate — and -/// the setup slot is assigned last (its failures are caller bugs; nothing is ever -/// half-applied). Released slots become assignable to later pictures; keeping the -/// underlying images alive until in-flight decodes complete is WP-B's -/// synchronization, not this map's. +/// 4. the setup slot is assigned last (its failures are caller bugs; nothing is ever +/// half-applied). `removed` is NOT applied here — it leaves as +/// [`DecodePlanVk::release_after_decode`] for the caller to apply once the decode +/// op is recorded, because applying it now would give the assignment back a slot +/// this AU's own references occupy (that field's docs carry the measurement). +/// Released slots become assignable to later pictures; keeping the underlying +/// images alive until in-flight decodes complete is WP-B's synchronization, not +/// this map's. pub fn plan_to_vk( plan: &AuPlan, slots: &mut SlotMap, @@ -276,24 +307,26 @@ pub fn plan_to_vk( ); } - // Mutations LAST, after every fallible step above (fn docs). Removals first — - // they were real regardless of this AU's fate — then the setup assignment. + // Mutations LAST, after every fallible step above (fn docs). + // + // The removals are handed back rather than applied: releasing one here returns + // its slot to the setup assignment below, and this AU's own references sit in + // those slots (`DecodePlanVk::release_after_decode`). // // The AU's own picture can itself appear in `removed`: a non-reference picture // with no free frame buffer bypasses the DPB and is stored-and-evicted within // one plan. Its slot must still exist for the decode itself, so it is assigned - // here and released right after — see `DecodePlanVk::setup_is_reference`. + // here and released right after — see `DecodePlanVk::setup_is_reference`. That + // is the one removal that is NOT deferred: handing the caller the slot being + // decoded into is the very aliasing the deferral exists to prevent. let setup_evicted = plan.dpb.removed.contains(&setup_id); - for &id in &plan.dpb.removed { - if id == setup_id { - continue; - } - if !slots.release(id) { - // Tolerated but never silent: reachable only when the caller skipped - // feeding an AU's plan through this map. - trace!(id, "DpbUpdate removed an id this SlotMap never assigned"); - } - } + let release_after_decode: Vec = plan + .dpb + .removed + .iter() + .copied() + .filter(|id| *id != setup_id) + .collect(); let setup_slot = slots.assign(setup_id)?; if setup_evicted { slots.release(setup_id); @@ -307,6 +340,7 @@ pub fn plan_to_vk( setup_id, setup_is_reference: pic.is_reference, refs, + release_after_decode, }) } @@ -398,7 +432,10 @@ mod tests { }); let vk = plan_to_vk(&plan, slots, 0).expect("the clean vector converts"); - // Binding sync: released slots unbind; the setup slot rebinds fresh. + // Binding sync: released slots unbind; the setup slot rebinds fresh. The + // deferred releases have deliberately NOT run yet — that is the whole + // point of `DecodePlanVk::release_after_decode`, and doing it in the wrong + // order here would unbind the images this AU's own references read. let setup = usize::from(vk.setup_slot); let mut held_slots = vec![false; slot_image.len()]; for (slot, _id) in slots.held() { @@ -425,6 +462,12 @@ mod tests { slot_image[setup] = Some(dst); pending.insert(vk.setup_id, dst); + // Post-submit: the slots the conversion held back, exactly where + // `Decoder::decode` applies them. + for id in &vk.release_after_decode { + assert!(slots.release(*id), "a deferred id held no slot"); + } + // Settle: outputs deliver to the consumer; removed-never-output free. for id in &plan.dpb.outputs { if let Some(picture) = pending.remove(id) { @@ -522,10 +565,28 @@ mod tests { ); // Mirror the map's bookkeeping: record the new picture, drop the removed. + // + // The removals are dropped only after the deferred releases run, because + // that is when the MAP drops them — before that the conversion is still + // holding them so this AU's submission can name their slots + // (`DecodePlanVk::release_after_decode`). let stored = plan.dpb.stored.unwrap(); assert_eq!(vk.setup_id, stored); assert_eq!(vk.setup_is_reference, plan.picture.is_reference); held.insert(stored, vk.setup_slot); + assert_eq!( + vk.release_after_decode, + plan.dpb + .removed + .iter() + .copied() + .filter(|id| *id != stored) + .collect::>(), + "the deferral is the plan's whole `removed` list less the stored id" + ); + for id in &vk.release_after_decode { + assert!(slots.release(*id), "a deferred id held no slot"); + } for id in &plan.dpb.removed { held.remove(id); } @@ -799,13 +860,22 @@ mod tests { fn a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image() { // Depth-1 DPB (Level 1 at 320x240 ⇒ max_dpb_frames 1, capacity 2): every // stored P evicts the previous picture, and that picture's id lands in - // BOTH `outputs` and `removed` of the SAME plan — so `plan_to_vk` frees - // the evicted slot and immediately re-assigns it as this AU's setup. - // That SLOT reuse is fine and expected; the picture-pool model's whole - // point is that the re-activated slot binds a DIFFERENT free image, so - // the delivered picture's image is never the new decode target while the - // consumer holds it (the HIGH overwrite bug of the adversarial round, - // and the .25 field failure's class). + // BOTH `outputs` and `removed` of the SAME plan. + // + // ⚠ This test used to assert that `plan_to_vk` freed the evicted slot and + // re-assigned it as THIS AU's setup, calling that "the planner's normal + // behaviour". It was not: AU1 is a P picture that REFERENCES the picture it + // was evicting, so the submission named one slot as both `pSetupReferenceSlot` + // and a reference — a decode into the surface being predicted from. The same + // defect the AV1 rung was fixed for on 2026-08-07, authored here in miniature + // and asserted as correct. `DecodePlanVk::release_after_decode` is the fix, and + // this depth-1 stream is its tightest possible case: capacity 2, so the setup + // has exactly one slot to go to and it is the spare. + // + // What the test still proves, and what it was really written for, is the pool + // decoupling: the delivered picture's IMAGE is never the new decode target + // while the consumer holds it (the HIGH overwrite bug of the adversarial + // round, and the .25 field failure's class). let sps = SpsBuilder::new() .seq_parameter_set_id(0) .profile_idc(Profile::Main) @@ -851,16 +921,39 @@ mod tests { .unwrap(); assert!(p1.dpb.outputs.contains(&vk0.setup_id) && p1.dpb.removed.contains(&vk0.setup_id)); let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap(); - assert_eq!( + + // AU1 REFERENCES the very picture it evicts — so the eviction is deferred and + // the setup goes to the spare slot instead. Without the deferral these two + // assertions are what fails, and they are the whole defect in two lines. + assert!( + vk1.refs.iter().any(|r| r.id == vk0.setup_id), + "AU1 must reference the picture it evicts, or this proves nothing" + ); + assert_ne!( vk1.setup_slot, vk0.setup_slot, - "slot reuse across the bump is the planner's normal behaviour" + "the setup must not take the slot of a picture this AU still references" + ); + for r in &vk1.refs { + assert_ne!(r.slot, vk1.setup_slot, "a reference aliases the setup slot"); + } + assert_eq!( + vk1.release_after_decode, + vec![vk0.setup_id], + "the eviction is handed back, not applied" ); - // Binding sync: the re-activated slot drops its old binding; picture 0's - // image is now delivered to the consumer (held), NOT freed. + // Binding sync: the setup slot is fresh, so nothing is unbound for it; + // picture 0's image is delivered to the consumer (held), NOT freed. Its slot + // is still HELD at this point — which is exactly what keeps its image bound + // while the decode op reads it as a reference. + assert!( + slots + .held() + .any(|(slot, id)| slot == vk0.setup_slot && id == vk0.setup_id), + "the referenced picture must still hold its slot through the submission" + ); bound[img0] = false; held[img0] += 1; // outputs → delivered, consumer holds it - slot_image[usize::from(vk1.setup_slot)] = None; // The pool hands the re-activated slot a FRESH image — never image 0. let img1 = free(&bound, &held).expect("headroom guarantees a free image"); @@ -872,6 +965,17 @@ mod tests { bound[img1] = true; slot_image[usize::from(vk1.setup_slot)] = Some(img1); + // Post-submit: the deferred release lands, and NOW the evicted slot is free — + // a picture later than this submission may have it, which is the only thing + // the deferral ever postponed. + for id in &vk1.release_after_decode { + assert!(slots.release(*id)); + } + assert!( + !slots.held().any(|(slot, _)| slot == vk0.setup_slot), + "the deferred release must actually free the slot" + ); + // Once the consumer releases frame 0, image 0 returns to the free list. held[img0] -= 1; assert_eq!(free(&bound, &held), Some(img0)); diff --git a/crates/pf-vkdecode/src/pic_av1.rs b/crates/pf-vkdecode/src/pic_av1.rs index 28da7f11..6a4bd9ab 100644 --- a/crates/pf-vkdecode/src/pic_av1.rs +++ b/crates/pf-vkdecode/src/pic_av1.rs @@ -834,12 +834,15 @@ mod tests { /// AV1 applies `refresh_frame_flags` after decoding (7.20), so `ref_frame_idx` /// resolves against the store as it stood BEFORE the frame. Cycling eight slots /// in a low-delay stream therefore means almost every frame displaces something - /// it is reading: **268 of this vector's 274 frames**, first at frame 6. The - /// H.264 and H.265 planners can produce the same shape — `plan_to_vk`'s own - /// docs name the sliding window evicting a picture the slices reference — but - /// neither vendored vector ever does it (measured: zero on the 250-AU H.264 - /// clip), which is why the hole survived two hardware-proven codecs and opened - /// on the first AV1 frame that was not a key frame's neighbour. + /// it is reading: **268 of this vector's 274 frames**, first at frame 6. + /// + /// The H.264 planner produces the same shape and the vendored vector never does + /// it (measured: zero on the 250-AU clip), which is why the hole survived a codec + /// believed hardware-proven and opened here first. That zero was a fact about the + /// vector: on a low-delay host stream H.264 aliases on 117 of 120 access units, + /// and `plan_to_vk` now carries the same deferral + /// ([`crate::pic::DecodePlanVk::release_after_decode`]). H.265 does not need one — + /// `H265Planner` snapshots `dpb_refs` after `decode_rps`. #[test] fn a_reference_this_frame_displaces_keeps_its_slot_until_after_the_decode() { let mut planner = Av1Planner::new(); diff --git a/crates/pf-vkdecode/src/pic_h265.rs b/crates/pf-vkdecode/src/pic_h265.rs index 896d1f80..d6fe2c1e 100644 --- a/crates/pf-vkdecode/src/pic_h265.rs +++ b/crates/pf-vkdecode/src/pic_h265.rs @@ -534,6 +534,95 @@ mod tests { aus } + /// **Our own host's low-delay HEVC**, vendored beside the goldens the GPU legs + /// decode it against (`tests/data/lowdelay-640x480-h265.nv12.sha256` carries the + /// `punktfunk-host spike` command and the ffmpeg cross-check). 120 pictures of + /// 640x480 IPPP, a five-picture DPB against four marked references and + /// `sps_max_num_reorder_pics = 0`, so 115 of its 120 access units retire a picture. + const LOWDELAY_640X480_H265: &[u8] = include_bytes!("../tests/data/lowdelay-640x480.h265"); + + /// This rung is immune to the release-ordering defect for a STRONGER reason than + /// the DXVA one, and this pins the difference instead of asserting it in prose. + /// + /// Both HEVC conversions release `removed` inline and let [`SlotMap::assign`] hand + /// the freed slot to the decode target. DXVA survives that because `H265Planner` + /// snapshots `dpb_refs` AFTER `decode_rps`, so the retired picture is not in the + /// marked DPB `RefPicList` is built from — a property of the PLANNER, one call + /// away from being untrue, which is why `pf_dxvadec::pic_h265` drives the + /// counterfactual through its conversion. + /// + /// This conversion never reads `dpb_refs` at all. `pReferenceSlots` is spec-defined + /// as the slots the decode operation USES, so it binds `plan.rps` — the three + /// current sets, which `decode_rps` derives and which therefore cannot name a + /// picture that same RPS just dropped. The test proves it the only way that means + /// anything: it hands the conversion a `dpb_refs` deliberately widened to the + /// PRE-RPS marked set (the mutation that makes the DXVA rung alias on 115 of these + /// 120 access units) and asserts nothing changes here. + /// + /// If this ever fails, someone has made this conversion bind the marked DPB — a + /// legitimate thing to want, since a *Foll* long-term anchor invisible to the + /// hardware is the RFI failure shape — and it now needs the `release_after_decode` + /// deferral the H.264 and AV1 conversions carry. + #[test] + fn a_pre_rps_marked_dpb_changes_nothing_here_because_the_current_sets_are_what_bind() { + let aus = split_into_aus(LOWDELAY_640X480_H265); + let mut planner = H265Planner::new(); + let mut slots: Option = None; + let mut prev: Option = None; + let mut converted = 0usize; + let mut widened = 0usize; + + for au in aus { + let plan = planner.plan_au(au).expect("the low-delay stream plans"); + let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + + // The marked DPB as this AU's `decode_rps` found it: the previous AU's + // snapshot plus the picture it stored. Exactly what `dpb_snapshot()` would + // return from above `decode_rps` instead of below it. + let mut as_if = plan.clone(); + as_if.dpb_refs = match &prev { + None => Vec::new(), + Some(prev) => { + let mut marked = prev.dpb_refs.clone(); + if let Some(id) = prev.dpb.stored { + assert!( + prev.picture.is_reference, + "this stream carries no sub-layer non-reference pictures" + ); + marked.push(RefPic { + id, + pic_order_cnt: prev.picture.pic_order_cnt, + is_long_term: false, + }); + } + marked + } + }; + if as_if.dpb_refs.len() > plan.dpb_refs.len() { + widened += 1; + } + + let vk = plan_to_vk_h265(&as_if, map).expect("conversion"); + converted += 1; + for r in &vk.refs { + assert_ne!( + r.slot, vk.setup_slot, + "a reference aliases the setup slot even though this conversion \ + binds only the current RPS sets — it has started reading \ + `dpb_refs`, and now needs a deferred release" + ); + } + prev = Some(plan); + } + + assert_eq!(converted, 120); + assert_eq!( + widened, 115, + "the mutation must actually widen the marked set on the access units that \ + retire a picture, else this test asserts nothing about anything" + ); + } + #[test] fn the_full_25fps_vector_converts_with_stable_slots_and_start_code_offsets() { let aus = split_into_aus(TEST_25FPS); diff --git a/crates/pf-vkdecode/src/slots.rs b/crates/pf-vkdecode/src/slots.rs index d7f1de21..34b730d4 100644 --- a/crates/pf-vkdecode/src/slots.rs +++ b/crates/pf-vkdecode/src/slots.rs @@ -44,7 +44,19 @@ impl std::fmt::Display for SlotError { impl std::error::Error for SlotError {} /// The slot ledger. One per decode session; feed it every [`DpbUpdate`] in decode -/// order (via [`Self::apply`] or `plan_to_vk`, which applies internally). +/// order. +/// +/// [`Self::apply`] does that whole-update. `plan_to_vk` and `plan_to_vk_av1` do it in +/// two halves instead: they ASSIGN the stored picture and hand the removals back as a +/// `release_after_decode` list for the caller to apply once the decode op is issued. +/// The split is not a convenience — releasing a removal before the assignment lets +/// [`Self::assign`] return the slot this AU's own submission still names, which is a +/// picture decoding into one it predicts from. A caller that drops the list leaks a +/// slot per AU. +/// +/// `plan_to_vk_h265` still applies its removals internally, and that is safe rather +/// than lucky: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`, so a picture +/// this AU's RPS dropped is never in the set its reference lists are built from. /// /// Invariants (unit-tested): /// - a [`PicId`] keeps its slot from [`Self::assign`] until [`Self::release`]; @@ -134,8 +146,9 @@ impl SlotMap { /// as the planner's DPB holds the picture — as a reference OR as a decoded /// picture awaiting output — and that residency ends only when a /// [`DpbUpdate::removed`] entry reports it. This method is that report's - /// primitive: `plan_to_vk` and [`Self::apply`] call it with the planner's - /// `removed` ids and nothing else may release a slot. + /// primitive: [`Self::apply`] calls it with the planner's `removed` ids, and so + /// do the conversions' callers via `release_after_decode` — one AU's removals, + /// deferred until its decode op is issued. Nothing else may release a slot. /// /// Releasing is CPU-side bookkeeping (the slot becomes assignable to a later /// picture); keeping the released slot's IMAGE out of reuse until in-flight diff --git a/crates/pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256 b/crates/pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256 new file mode 100644 index 00000000..68969458 --- /dev/null +++ b/crates/pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256 @@ -0,0 +1,126 @@ +# SHA-256 per DELIVERED frame of lowdelay-3840x2160.ivf.av1, DISPLAY order — 60 frames. +# +# Each frame is the 3840x2160 render region as tightly packed NV12: +# Y plane 3840*2160 bytes, then interleaved UV 3840*1080 bytes = 12441600 bytes/frame. +# (`render_width`/`render_height` equal the frame size, so there is no crop.) +# +# THE STREAM IS OURS, not a conformance vector, and that is the point of it. +# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04, +# punktfunk-host 0.25.0-0.00011708), 2026-08-07: +# +# punktfunk-host spike --source synthetic --codec av1 --width 3840 --height 2160 \ +# --fps 60 --seconds 1 --bitrate 1 --no-loopback --out lowdelay-3840x2160.av1 +# ffmpeg -f obu -i lowdelay-3840x2160.av1 -c copy -f ivf lowdelay-3840x2160.ivf.av1 +# +# The spike writes the low-overhead OBU stream; the IVF wrapper is added so this file +# is framed exactly like the vendored vector and `common::split_av1_aus` — the +# vendored parser's own `IvfIterator` — splits it with no second implementation that +# could disagree. `-c copy` re-frames, it does not re-encode. +# +# ⭐ WHY 4K, when every other fixture here is chosen to be small. It is the ONLY +# resolution at which our encoder emits more than one tile. Measured on the same box, +# same command, 2026-08-07: 1280x720, 1920x1080 and 2560x1440 all give +# `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` — +# `width_in_sbs_minus_1 = [59]`, `height_in_sbs_minus_1 = [16, 16]` — and BOTH tiles +# ride in ONE Tile Group OBU (`tg_start = 0, tg_end = 1`). That is the exact shape +# behind the defect where the host shipped only the first tile of every 4K frame, and +# a single-tile fixture cannot express it at all. +# +# 60 frames rather than 120 to pay for it: one second at 60 fps is 261 KB, which is +# SMALLER than the 282 KB H.264 and 270 KB H.265 low-delay fixtures, and still leaves +# 55 of the 60 access units exercising the reference-slot pressure below. +# +# 60 = 60 = 60, and that is itself worth pinning. Unlike the vendored vector (250 +# temporal units carrying 274 coded frames, 24 of them hidden), THIS stream is one +# coded frame per temporal unit, all shown: 60 units, 60 coded frames, 60 displayed, +# one KEY frame, zero hidden, zero `show_existing_frame`. The parity legs' frame +# accounting must not silently assume either shape, so the CPU guard asserts all of +# these numbers rather than deriving one from another. +# +# Main 4:2:0 8-bit (`seq_profile = 0`, `high_bitdepth = 0`, `mono_chrome = 0`) and NO +# FILM GRAIN, so the Vulkan decode profile is the grain-DISABLED one, exactly as for +# the vendored vector — see that file's header for why grain is a profile property +# rather than a per-frame toggle. +# +# ⚠ WHAT THIS FIXTURE DOES NOT COVER. It is a FILE, and a file is not the wire path. +# The headline "250/250 delivered frames bit-identical to libavcodec" was true for AV1 +# the entire time the host was shipping only the first tile of every 4K frame: that +# verification ran against a vendored file, the packetisation and reassembly it never +# touched were where the frames were being truncated, and the suite stayed green. This +# fixture closes a different gap — it is the first pixel evidence for AV1 from our own +# encoder, in a multi-tile shape — and it closes NOTHING about fragmentation, +# reassembly, loss or the session's AU boundaries. Those need an end-to-end test. +# +# Goldens from libavcodec's SOFTWARE decoder (AV1 decoding is exactly specified, so +# every conformant decoder is bit-identical): +# +# ffmpeg -i lowdelay-3840x2160.ivf.av1 -f rawvideo -pix_fmt nv12 \ +# -fps_mode passthrough ref.yuv +# # then split ref.yuv into 12441600-byte frames and sha256 each +# +# CROSS-CHECKED between two independent builds on two architectures whose +# 746,496,000-byte raw outputs are BYTE-IDENTICAL (not merely equal per frame): +# sha256 90c5be20342cba4d80bd0ceb1568cc3e2037f427d34c658895c7742968c93600 from both +# ffmpeg n8.1.2 (Arch/CachyOS, gcc 16, x86_64, libdav1d) +# ffmpeg 8.1.1 (Homebrew, clang, macOS arm64, libdav1d) +# 60 of 60 digests distinct. +5c587f77a16733533c178c115cf4fad5866b13ebc4bf7673a72be062b4aab9d9 +b07527d4a8d7fb8b4ce53d97818e86493237e98c492e5718d42ea0974cd00da2 +59156f4f4d3ef030fe884e7085a2f1b3887ba00e0c8e58c5e3f425a31f5aade6 +693bbc7da0f499d2955e6c13ed6ee3c13603faed938bc357264c534bed9faa0f +14d1722b39ef61ede697a87f5d11eafcf7d9a812fb966fd3142c483036a08b87 +94e6cd41d300298677b014650e5f19b420c38e82741452da573d189d947f1a41 +f832d8126cdf3195be4a25aa5e80eefdd2c1089dce5bb3d3531f9bdb58e9c3b0 +be9248bec91e4bd5ab06e3a32d1ac02b759ae94735ecc88b14b845c5e4f24120 +581fdce69ff5188c949612fd00bd801c717ab9fc1f2de635d734879aae0a7bdd +9256582fc2d242e57a2fbd414a481d4cfca494fa56254bdb15004b4d65c866c6 +7955081dd59795399a1cb7a3eeade261ba01ef89c8c27f1e133c958be054ddec +5c8e963f60385add18001a3992fe5c5abb697391213f0f992144e5bba8f00745 +13062b6a6e2c557f4b8956a2f05a9ec941b3bd15b6ff4c337662b3eeabc75c41 +a80151a16345232be1db60fe2c1e7e61df38a1f676868fcc1b367fa42cdfaf9e +3bb8d3934ce8b772d922c3f9004a2ce4db0bb1b9dcc63c5ac2ac90900b5693ae +0e2b7d0148bcb1873256fba13058607b6088dc0478e9ed4773b657cd5d2d736f +a620a352fd7f71675ea408a53c65e193f986c4e03da8008df996858410804323 +d89b4fdf771dbae3422dc193b8425af78a8a27520a09f2c8039fc6bb1501fd9a +8bbdfbcb580c3c2694b8666869abd4cc39083d3dcf68d9b9ddfcc0f87ba8579e +f779dcc8cc901b9fd878ab7f7e0b74ffe6123711fca9a9cde5c7f79f8edfc2d7 +172994574f60daea3dc17097a9ef001528cdd469388ee1c168021e622fb9616b +4ff8294a6e9866cab539ec30a434484370b74b529d4f1b8a83186b6cf3862abb +4a23e983efa8d890f89f654813032996af9ec74967e0bfe3bde9c1157e7c31af +3f35ff00a47818184adcc25d86546503d62dd15ee1992971fe8c8d378fb1112d +dd39f5cb55e8ec815dc8608f59612823cbfb96bdf4a470c9255ce745fbe43a7d +62208c265976740965ae43a75c63e18eb1f6dce9781c98db105c4b4e6bcaee05 +a4227c20e5b717c8d6f3bc02290bf5c6f14ffb5655ee0b5d56ea906de39471a8 +99e146b61d3591d32f297b87eb02957bbfe7a323bf6b09da0f25840018ff3168 +7abfa53dc8d8902eee943cad6bd3d9893954c04f52ef999f64822ebab935f474 +69a8db91bc8767047ee167362d235c494be63957efeef400e12d447c65136438 +8786d36d405ab6b2493e98415784e33fe09b49fe9730ad899ec1db418ab71122 +fe5bb80bdf4a100305b157fb1f60732bebb9c7b70ab22726004bb4fc9925705f +264595ab85a2f8e30d316e48b02d8863c341d8ddc9d34e42affbad9520958027 +cc96871daa10607f13227d6dbabf56f9ffea7eb2acc4d71272cffae286da1970 +911bf0ef3c152bf45f527de8b4deb5a60d80f5793bf7488dd57009b1cbdf51be +543851960eb05d8b2a0a3b7ee50ce6fcc975b4ac6404247464e535364e610804 +e650439e22ebc94c24479a3d28472afb776389f4eb9aff62c8eaa7b96223e014 +3ca9d4ba265c19540c73eb51c9d4003ca3b762cf4d89f6d771d6c9edb07df65f +f106640e15ae6b53056c9fb64e94b2a3ccce80a32f8146a5cb170363c1a5d019 +b7004a617d142bb7238063c35e1173888aa028282fdbdad8ffaca3519fb0228d +98f03d7add2fbd046b6805514d0844abbf7c2ee43de2509d3344843ad2d7220d +15c7a6c87d1ded36db5b5aa9e8394b184d3a6c7ccee4e1041b2473db5a1d0d2c +8d98f9655751c2c2afc7d4a87dac58c0d185e7bf2d897f93b0e999cef7897920 +91cc279dacbdae06ca5cde05c395e73cb675802e1a4f1ac3ae95a0344e2ef6ec +d042152a54fb3a0c129c2beba6c73609c4e9e4abb77d5c0bd731832bd70c6ab8 +5a9a9ccfce845caefcbfca7ac553674f84ff9a7f2846c55541512290a89a9295 +6ed1c17c84a6c454600b079ab709782cfd494adc5433613738158c5163e60211 +116d24252b578e6e4519611b9542372175b3fbed7ff76276daae6e0c8d2cfa77 +edaf4b5827b58b5712266d84914706b76b4aaefffa2e70a703f5261e9c774c00 +85b63f4a86b1e5e19372dfa98040382c94114bdff710b301430ec1894c0d06ab +bf19eb95a3fdeed167d2a2a65fbbcbd426125451de649d018f7c1e2bdfeced50 +e0dc2d2422909b997cd39d2d66842f2f9da9f9002f6b46b93ed2666a324e04ab +68ae1aad5ed221897100680c932f5c5ce71ba4957cc6b784a9ebc0775342d714 +784acdc2a7cbb0cd135c01dac00d4d63385fce60f4d053d30e9b972d97bc6db6 +5a08dac838c5f012e79e60cdf17ab25db8b58ad83df9b2d1b94a7c1f40e04943 +0c53d382480cc0ef5402dcd71d80c23662a9c3078b3989371eca11a4000cc4dc +a50fafe9ad90c4cc77dc6ada3184e8a7fcd744fd57855ee05d40cc96e6cc10af +eb1b41ec865ab2b6090cba6603c687bd059aac1d183ed76846cad1b9a579c7e0 +92d9d93b9cccb596ac7022aaca97e32b4ec7417dc8f0047a9575957464d6b5d5 +210d44dfefe088b47caac2861171abdcef07c828ab673fa98a6e64cda8d42107 diff --git a/crates/pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1 b/crates/pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1 new file mode 100644 index 00000000..78ac2c5f Binary files /dev/null and b/crates/pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1 differ diff --git a/crates/pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256 b/crates/pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256 new file mode 100644 index 00000000..be51684d --- /dev/null +++ b/crates/pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256 @@ -0,0 +1,165 @@ +# SHA-256 per decoded frame of lowdelay-640x480.h265, DISPLAY order — 120 frames. +# Each frame is the full 640x480 picture as tightly packed NV12: +# Y plane 640*480 bytes, then interleaved UV 640*240 bytes = 460800 bytes/frame. +# (No conformance window: 640 and 480 are both multiples of MinCbSizeY, so the +# coded size IS the display size — the same reason the H.264 sibling picked 640x480.) +# +# THE STREAM IS OURS, not a conformance vector, and that is the point of it. +# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04, +# punktfunk-host 0.25.0-0.00011708), 2026-08-07: +# +# punktfunk-host spike --source synthetic --codec h265 --width 640 --height 480 \ +# --fps 60 --seconds 2 --bitrate 1 --no-loopback --out lowdelay-640x480.h265 +# +# Main profile, 4:2:0, 8-bit, level 3.1. LOW-DELAY IPPP: one IDR then 119 trailing P +# pictures, ONE slice segment each, no B pictures, `sps_max_num_reorder_pics = 0`, so +# a picture is output the moment it decodes. Its SPS says +# `sps_max_dec_pic_buffering_minus1 = 4` — a five-picture DPB — and 8.3.2 leaves FOUR +# pictures marked in steady state (116 of 120 access units), so four references plus +# the current picture fill the DPB exactly. Each P names exactly one of them +# (`numRefL0 = 1`); the other three are 8.3.2 *Foll* pictures the RFI window keeps. +# +# That is the H.264 fixture's shape in HEVC's vocabulary, and it is what puts an RPS +# drop and the eviction it causes in ONE access unit: 115 of these 120 access units +# remove exactly one picture. `test-25fps.h265` cannot reach it — it reorders. +# +# ⭐ HEVC is nonetheless EXEMPT from the aliasing that cost H.264 and AV1 a deferred +# slot release, and this stream exists to keep PROVING that rather than to be the +# stream that breaks it. `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`, so +# the 115 dropped pictures are never in the marked set `RefPicList` is built from: +# measured `removed ∩ dpb_refs` = 0 of 120 access units. Move that snapshot above +# `decode_rps` and the SAME 115 access units alias — the counterfactual is measured, +# not argued (pf-dxvadec's `pic_h265` tests pin both numbers). HEVC's freedom is a +# property of the planner, not of the streams we happened to test. +# +# Goldens from libavcodec's SOFTWARE decoder, the same ground truth every other +# golden set here uses (H.265 decoding is exactly specified, so every conformant +# decoder is bit-identical): +# +# ffmpeg -i lowdelay-640x480.h265 -f rawvideo -pix_fmt nv12 \ +# -fps_mode passthrough ref.yuv +# # then split ref.yuv into 460800-byte frames and sha256 each +# +# ffmpeg version n8.1.2 (Arch/CachyOS, gcc 16, x86_64) — cross-checked BIT-IDENTICAL +# against ffmpeg 8.1.1 (Homebrew, clang, macOS arm64), all 120 frames. 120 of 120 +# digests distinct. +9f9c0286da4917dd02897f75d61142f0e6e950c2795e835f6afcc849d6778e5b +269e68e0917f62050cc6e134a9355d621dbb6dd1ab162f809b86c44df3d8fef0 +3297b14670d010b01f7c5afba4a1309055965d4b1cbdcf644f3ae59540330e79 +f6f7b529995b9d658a7a30a2b8df8381a494b18d4a92441c350591e2221656cd +c32d3e28e7322d08a5a4c2044b3d6f6a8a5b093818e378e9f68ecbf787c3e89e +51b534a44bd5292dbfe189108f0cd2ceaeafd35433762113d25ad17aee95ee9f +fe23e229a7154d5ddd965e7131a3db1928fbcc91525b96d06bf354ecb390cd36 +a18313577c9f658af750ac5deb71275b7cb31847030064b7aa1eabfa1bca8b6d +bd372841ca00e7ed8cf88bd5b47f1d2aee0a87c9c40fc1d921ce5135c5ff0782 +baa5e7a7a2c86ac428dfe3ed3bfdbe399ff8f339186c48c181840a738010e6a7 +43e84f559307c12c0a4d8acfd259cc3a73aa6d9e2951dcf05c45e4185becf75b +c85779050cbd80826c554e62d5b9435f6e6cc31c0013d288045260fe576dfe8a +93f5292b0436b37290e644f0e45aa987e39c97666f182e54709eae42512e40b7 +163fa586ec9813079ed0427bfa046f846359c540763b4437062c181a219d64b2 +2851fa698eb3889235d16a7e9497153b57f6e1ba79133f038586c13623b1893f +3ca46e9b37fe4c0330893f5d91ed9592bfa65725d703be58d00bb57d2aedaa8d +30a70c37b70f224198c09b7606464fa9f2ed578cb33a467832b2864db01bafe6 +7cd5448fc7d149f311fabef84893bb91b5127c22fdba429eabee1b10a266eb0b +79bda1e5dd8136eb2dc5cfc607dcb1bdd2a3b58a3c85133f52e54f21bb138e99 +9aa3f7dc22d68dbf76cb1a71fd476f5f8b272b7dd6676383945c37611ce26f56 +75b1151b63f8e071cadd3656edae21e6755cdef194730e4d9d364df67ff63b56 +a6fe1a709aa69329fa96ab90aa4561a57b42cdca1d6dc3f41b00b8466bbd2bfc +a309129ad54517d8839ca4ebf12bdcc4c563049fe991d8a7c0fa57ba8c75c6ee +dff37bb18055c1f4739168c882ec694b027998b960e341d6815a47cd87942a14 +ff334206b81c8664cf2a59df91d0cd3fd3bc8f45f6ec918312df75e61d6adf74 +916f50363a424907d97dd1914808770ee06716f70c6040a44d0c1c9c66004b3c +a2bdd4550eb5a43742d667a1538102838dd81e7930c76b607df23dee4d0c2c03 +e5f70c0a5df394b9dbe49bb6cefb0c0ce799496ee1cb02ba85366415376d9863 +f154dac657645f5aff1e0f47aa8ffd66b7205808fdf345c9b153a70770eb0702 +5d83483a3135908c233f9c8719115b823ca4c384cc3d48f9d43b2ce174e55c12 +60ea060bd5f80d2ed291f7fd87a317adc8a8eab5f2ea0bcda130e06f0bc4b21b +3cbd406035c302f48874c509f0a2f32f2a3ee6072d87a97ad91728abe9778176 +43c319ed065cb55d59a6d9fe98a70884563ce11109e54dfd99d5a4ce695ef355 +a6416844bc2d08e80415350ea6a77b37e0a8c5ea0a13bb284ac6f4f3a54882a4 +b49fc7a97971eef0c7ce3264a5245de9c323fdb8c1f8d5410f5ffda6bca6c187 +23754bb46f0938a29cf3b58cb78530701b3ed7e8a2d6fc55a168b2b0e6c9bf19 +9bd927da6114b539327c3c9010e01a19bea941fac296acc7cea4ff59ee2e21cb +774b5b0f9872c4d95f071ea16dc848d99c7bbafa22228d6530501d1e9c405e22 +ee244d9663d700085033571e63649beb169f3464b5c47b435beb5e56b6513033 +c552bc82788cb0b793361a3beed54b0f1ccbb03c1a34d29491a07acea615b980 +31a92d0c3a8906c742034f36720481d50d2b7913f2f8225f78ef6ab8fb104a8a +3f0581af032d83dd7cc2cf0afff04d225a48a42dae3e3cfb6cc72b1218b49dc8 +6f0bb48a98e7a971238bb4d90432ffc675c55bab2571387e0fdc9f2d8acfdc7e +4f51f9aaa83108587eb3871a22c7ebb34a7f66dbdd81689de03f10f16f8e3207 +90a47f56c6aedd15de6dc3798bab53f394c4095c1fea933f2d8af83763d2431f +1e8c8505e4e9f7ada9a60b6d6f66d30961c3f5fe70777a71b4e110079a435164 +6909300c3db9cd6db449bd6e3002b94052da02000105f89bf38ad4babab4e339 +caf9794215bd2ceebbabcebbd7d3de3d86ac9fdced421ab30ef0cb1eea9f472b +232b867d97898839e331b9a9aa971e5593b4e360833ae1230272d426fd3796a5 +3472664dc348f434ffd0b58215cbdd17c9d18fb8112c0b1bd3781fd23da77edc +cdfb83932eecd4c5acaa12bad0d7f04d2a87dcd2812cd0a10ff3ddab3ced0484 +4862527f8256fa9c1817c7db32a9237ca9078554de336c7ec231cdf92fd8b481 +95ef6abd712678dec27238e9f5d983c632ea606c68a17db8c13db5f984106753 +2a37fa16ac36bdba0a934a4fd756bb0dc40685c4db63cb1c919f1737e2bcd497 +798d6c594aa1924205e947e8b48a49822dae5310f1938e31d7fba6b538bb0689 +f7e8fcbfe4074d0e8cffc88ac5be30cfcaae2bdb4d6f35b32c627ef1eb1e5b44 +87eedb3cd8a47c70cf23c7025a53176dac83d9928d66bbc9750169b15df65fee +e3f24c0c33a4442c7f3ccc4fdc4d42116a92c1afa2a108d9867493c25971c6c6 +6fba290349aea3ad2f0b189ddf608c07e3c7062e5659464453e9198f80fb1a06 +fb1503234b54566ab4d4fb8f155ec779e6e83c14eaa2ebbc51502636b8142362 +25a2d7396d612601b65306d166fa9353eaa63a820c88a4a2ba4891ebd6527c73 +52e600c978c89b4e0580ea63a4131edd445cb1a83d5978db99e09bf115795c43 +c576c1f537bd10065c93ce01bee7821b5d947d418c42f2941a65c106a8866ded +fd925cf61ffdd98fad5388c8bde2f3e4dfaaff0de0badd3e28998dcdaaa22790 +ed06b90b70480467e2bff3aa44fe26356065393b09916ca55b705147e79a4532 +bc311e2abeeceb28e409a5c117a303100a5b8bd72a9212da8370ed125d23267f +611f87e03ab96ef0e683968611f9ee9b6c0052457a513217f9a20cfadf98c0c8 +781aa26b54f6e3a941c973c1685323ebb6a4c8a024d575a219138d2f7500f12c +e1bf924e000066db999ea9df03f444544ef9b5ced6903fd6e11679b1bcde4288 +1dbbe9f9039f67d630e0474830a9b4908453dabc705a726990e5da33f8265ef0 +6b196b22ee1b5f5a41129fd6ea4c31217010d656d07eba1b519372483e225d5a +f98c6557358d7d69ad084a39780e46c3f6d2c882a95b14e196453cf5683f2e1e +2d1f2d2e730d80b0a0e9186cf706fee66e5919b724cc048fc26905766baeb4fb +fc6e7638091a5a8fea2c2fb93de326466d0b8b2791a137b6c326cf873f780091 +12ace0c4843c04cd1ab0b24d11374f92862300dfb04402c53dbb2a3846d3cd1e +ec3b094cf0a85e64794afb1a423fcf6ac2d4ac49bcd0eafaff93c87c5ea5da80 +4eabff56ae2893cb8d18b994e8922d98517f4bfc26f03572917ee04e9c1f2c21 +c836a55b5690dc31db03d5373b65660a7a56b3dd6ecb1335daecfdd798dbae49 +3513c96a153957e34cb5b4568fed9cbc9f008b40c7f7c8888419e280b9552e72 +98d993995f82e40736a96d78088f79ef5b1e23a2f2c8612a995581d2e4339c89 +a7e18f4986c14870c0f5703287acf0015e84265c59cd81ec4da249a156eaf344 +c67d1b2cdfd341d605fb1a0104fbf39dca7a220a20864f6e258c5261b363d2d6 +d49987c3cc626dea121b049f26ff92619fabc0c9ac2f4d7222fd5a4f3cd05106 +c1af9fdac737422cad1e70fae5660268f51c1fa2ad565617d9aef0c6567bc1f2 +988b41b171cbefd8844891dbf1dda9586f4f7d855fdedf9084ce1a35211bf4c7 +0c89259a251df6835d43f2d41b3ab680191ff8a2a1b52f38067f5ff6e64c46ef +463acd75505bf1c7f9cdbac61ffc447fe7dff36832dd6df2da8c6fb034a7ea5d +b87b3ae2d51b989010cbc44d020db3be1df563727cb1828ed20be75138b391ca +f224bab42976a46f976e3792ec15c7c143fd40c255577ced88c4dc423f9e521d +83248fdcccac574bf2550594894668e2294b60a51e02511bd7ed1a3d62a0a833 +e21a46b8aa370956a53c6059663c95846d6de9c21b911e9b35328642cd2b6a93 +1b25f894a5e039940be81195d73bc47b81dbe3de0a02172c47e8f3de53ff453e +b91a7d7f4545adec12ab1f1646a152aa7a411f52fe85ba6d0a05e26233bd9554 +76fb5d9740f2f31d27c017d14f2641dd71debd5ce1322f993e4d4525f86913e2 +f69e6a8e6d1f9022fa3b4d7e27cae866d827b0d70ed4a1475cec54703e3028be +89e799beb6d7196de0f1aaec171c0aa7c5ab5b5061e7ea6431a4b9ca2f9f305d +a48dd4d45122187814b66952700536336d2d86e3cf4c90499416ee4178c82df6 +716723e849b5a3fc13460c45b7b4f236bdb077a40eae3f1bd6f63970eccfd6d1 +6d6f03ccd7e223c150bf4dab4667ef4cca83ef24db79dda73c1d3d132cd8abec +f6b9a0795d6202a1f868768fead3a3072f5ac84a31bb741b1c2e6435bddea335 +93db9ff6332ea465c026c6d994e214d7fc035dac3b28cc5df5100633c4b3d355 +f2f7b1a021abe83b5635da215b72d341c58532fe0aaa6c72570e1ab4d8bd702c +e1cc57cae82018761af96d75485c9564c742b9c180709f41cdac28a08b4734c1 +bd8b75c1bddaca942b0645efd1774ca6cdf2b1254f65aa3bb52160a635c5820e +454d9a6027f89a23680378b0ff469b0ddaa277f1daa1fc953ffbf3a86c9b56e2 +52dd5395d96d8097515707287fe2f53c140e46addca3d4407b1b9f9685569ce3 +df850ef27b8e2db55f1f03e4fe686a22897b5266e37ba6d56cf97e9adc0a6e25 +d6ee7735b22fa97e1ee85fcbf7bea1609ed703e98a4076e1474e8d152794ec6a +4bfcb046b8175eed1732bf52d5abd0540d73333e1b3efbf86b8dfb1a76aeca57 +632c87450e3b5b763d87890b5ed5ce38775af5359b858f716cfbff651838c01e +2784dd87812c4df4793ff50409626d47bebd4149caa4b5098efb5ec63c59b5a1 +480b2f63b02ba2436a3d026701adafc27ed8fbc100ebe232257ec62c54166767 +81d1b8196e7bacd89ef90494b48772b1eef52afbee49aaf907eb264eeb11d660 +a045bf5565f399c293363f2d08315b60ae6bd6a524ca537668e3afb3b9893d9a +767ffcd6a3b2574f641c1f298c4606cce88b08f76ff5abbdbfd8c8cc8b88cb86 +54cb01c9f411e1994b5a2b2cea22032e232a1d76874b5b6a991474058c3b3dfa +feba0cc0e710d509963cf7f7c6bd62e6904d6c5446fb784bd3508520e1a3371a +ae0c6d458623d31eeb59e9170f983dec17ca5885614dbc82e67ae45b9ecb11c9 +e6d0f8d4ee60e94199ee39ed6f7b2f6c4d4835993c981086f5b022ba4bd1da6a +d70133bc51be4b64510a0411c07dc2479b04f18b1b561cce21b91f6a3a9508a2 diff --git a/crates/pf-vkdecode/tests/data/lowdelay-640x480.h264 b/crates/pf-vkdecode/tests/data/lowdelay-640x480.h264 new file mode 100644 index 00000000..5b56588c Binary files /dev/null and b/crates/pf-vkdecode/tests/data/lowdelay-640x480.h264 differ diff --git a/crates/pf-vkdecode/tests/data/lowdelay-640x480.h265 b/crates/pf-vkdecode/tests/data/lowdelay-640x480.h265 new file mode 100644 index 00000000..fb8bc79e Binary files /dev/null and b/crates/pf-vkdecode/tests/data/lowdelay-640x480.h265 differ diff --git a/crates/pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256 b/crates/pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256 new file mode 100644 index 00000000..cea58232 --- /dev/null +++ b/crates/pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256 @@ -0,0 +1,149 @@ +# SHA-256 per decoded frame of lowdelay-640x480.h264, DISPLAY order — 120 frames. +# Each frame is the full 640x480 picture as tightly packed NV12: +# Y plane 640*480 bytes, then interleaved UV 640*240 bytes = 460800 bytes/frame. +# +# THE STREAM IS OURS, not a conformance vector, and that is the point of it. +# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04), 2026-08-07: +# +# punktfunk-host spike --source synthetic --codec h264 --width 640 --height 480 \ +# --fps 60 --seconds 2 --bitrate 1 --no-loopback --out lowdelay-640x480.h264 +# +# It is LOW-DELAY IPPP: one IDR, no B pictures, `max_num_reorder_frames = 0`, so a +# picture is output the moment it decodes. Its SPS says `max_num_ref_frames = 3` AND +# `max_dec_frame_buffering = 3` — the DPB is exactly as deep as the reference count, +# which is what makes 8.2.5's sliding window unmark a picture in the same access unit +# that the C.4.5.3 bump evicts it. `test-25fps.h264` cannot reach that shape (level +# 1.3, no VUI bitstream_restriction, so a level-derived DPB of 7 against 2 reference +# frames) and REORDERS besides, which is why it measured zero aliasing while every +# stream this program actually ships aliased on 99% of its frames. +# +# Goldens from libavcodec's SOFTWARE decoder, the same ground truth the vendored +# vectors' goldens use (H.264 decoding is exactly specified, so every conformant +# decoder is bit-identical): +# +# ffmpeg -i lowdelay-640x480.h264 -f rawvideo -pix_fmt nv12 \ +# -fps_mode passthrough ref.yuv +# # then split ref.yuv into 460800-byte frames and sha256 each +# +# ffmpeg version n8.1.2 (Arch/CachyOS, gcc 16, x86_64) — cross-checked BIT-IDENTICAL +# against ffmpeg 8.1.1 on macOS arm64, all 120 frames. 120 of 120 digests distinct. +be912b67b89d720b4e9403bde4c52f0e4414571e3323e4b5312ff5dc26309dd3 +87115b7a352e95baec35ad8bbbe45af5ac6c691264291c9c904e4d01c2e6f911 +03ad701f6c1c4421ad6f8396ed24b893c275d245cf93d24a4f7cf59f9c74675a +faee80c2c494b009b6bab7d9e6d6cb5ccf0921cfa1bdba0d83479b19d2a6f012 +a01b6234ff0bf5c222a0de7b574075f7dec5fc4ea3ccb6657941c791251c6242 +d0befc0eb1e018b671dea55d74009ef1d09cdcba392ecd8509ec000aae91fe1d +f15c273a6077f73296edf4e245069299c034e4a3f4dea10006d9bbf29f8f4220 +5e08336bba0a20ffd3efb8659424af9b76da0252c0cd5f1ccfcdbe92612bafc9 +a8bff0f9921ae301c17e83885760e3e085845732f8d48c4a0fd70f591e3043af +dd90da5b869d1026fba4e41cfba059b8e75fca383bd1ab76c493a82271e1141b +e9237c44040f78e453129daf71afe5e24a640a67524b1c8460c9efaac8eb4795 +fed123e00f51dab09a4b9964bd289c9db95b6a83564d35a5a5e3d5ac2ab8f371 +d7f2db0a52f5a0d835ca9d7d74ec30da29b36bba94b30c10058715c505e26cd9 +25ac9facc9d25c85d0d30864985a0fcad635bd7e65e4773a03e620cbd510ea97 +6d6558eb98c4fc4c8a3dfe9e9eefd744b3cf66f66c6f09c40afbaad272f3f16d +678cadfadfd980eac5aa8ca83eeff2112249262cc4747fac49ecba49a8fbbd42 +031d73796c6f2b9589bc1443937f77156d52aeb602fc7fbc7ec34f02e1ba33ba +59f26c57bade55876dc4ef17070a703cb5585f059f8db7a477a752e731830615 +783d10022eb2f8749f7130954b899c74ad950fb98305a8b0f83ef5a78f38207b +e8d8ce42e46c4121be8a270d2bb30c98a3d11bee7808dcacbdb19e9318e66989 +0b2fb3507e886953ab801e03a63715e6a6a26088de9fc809a56f454e1e2da069 +8bc350ca94eb356c1bd2f221d482eb6e6cdc6e132366236c8ec4b1a41289a01f +dd0f472b9c78ce5b2b4fa2a2354c46090f208fefb12af0440e9a18a2d61b3167 +08bceb5c011632733cc14edf01c301fa4ffe2ba5ee30424f518a23c908db7168 +142b77142fa169ebd231308b0f3162eedeac66bfecb60eb9493abe16bcccaffa +c1dec8e1876afb17d0881c6df6e32e5ee1d8d931f3bbccb079f3ba44380721a0 +e26bc6604826dd69d778ec77d0d3823a797faf32f838f3e13aceca3af15ddecb +bc7b93e2a39d2102539587d3aa96c4aaa58a13050a881bd4f9ced1411bca3a48 +f41bfc62cce822129d65952cbcee15345e5a5fbc529acffdb223851a6f24fdce +3d3b23a6f161580f2f82382c01901ebe0345194d33e07981c50d4cfe0f39bf02 +6e86c11618e37e770667245a679d359fe4a2f42c2a31ccbdc8eac2ee1a299fcd +9083b8c5455f513ed558954892e43b2b18c90928aa7a098ff49f4eff153ede34 +9037d398f4e7cf295ad3bb43069c6a2fa96769c9b20617c9bebd1f34ccb1be3d +a333ed7ba7c7403b9b3a548e1a5f1669218ad85e47f90d69308eb037b4729330 +aabbb4aa194537e260d5a11603fd1a9ef704d784b4d0ea592f7bea6d2a23af17 +a46f730e965d4e39a3120ea43cb02ac94342073b1cf1e473e8d43c49b0cffae4 +259d098d3f53248e889beff1517e4ca88aed4e3803b4ea4923d348c8109f5ac9 +3fc9bc21d3420726606a5534c6516b1ea0cc2140392e0bec2855a45f6f090b82 +2f3cebf32859649c29b1665c766fec4dbdbff2003e64be9e4720971d9b6ae2f1 +f756e4459576a5aeb4c260c9a6c8e115e43ec97a64bfe74fe128f03c3c61b9a1 +d6e101889565951515168958141a23950eb25637dd5202d55d24885eae4e1b42 +1402b95affc25769e00d8ef30eeb0a831bacc41649246e48a46bac1c362ef3a2 +7c838e5d2f191908e2d488185d209fe51c29a50e822ceb1a1f22b6eb6f28fcbf +ba39e5f0936af9706d9718390d03713a235b57102904e2a3972e67ff11b30800 +cfe7b79981886bf74c50957f30b1a98bc4a064e2b41f8fb3996b0a3e15d132ba +47a02c20be1d34caa0a0d406a12d4970dbf642085442663070a8ae942323eb1e +495c75b4d163828750d0e828243bcbff08354418dfd2d43c5d1fd93c3bf691a7 +5ed8d82a1aad0f27066028b4b1058d29f0b9ed5f424f378315ef18c883fe9fc2 +85a9bcb2df1feb0053fc707028df68e9fcf3aed326ec2438ad111dc264aa69bd +b8247ee3d57539fe80bee41669a8a4c6955fd1e374b33cdfe6a6ad9fbb7d9fcb +bcc0a47694f3074d1c7b812f9d00c3eb57f98ee981d8acd7cb504bbaa04275f2 +c5ca305e54f839a7e6f46d3280c17d81f5b3507310bec9a7fa2dae6a151fafd9 +f5807f97f81927b2226395fc27a5d91eef56732916d9e78f65eb485fc4b846ee +3b9d1302b30c11e91eab92eedfcc9dad71a0382488808acc851589740f01498f +619f4862e224fe000acb4edb645fbdaf9e2784e4917e4e3c994052df123c701c +dcabed23f10e192e6103c032f6049adc2e30379f5f71e62489008cfc905f4953 +85dac7f9dfe772670adb9087edc3825c201bd1dcfef412243e26317f5115c6d1 +3518fc0bd4ee33d08976a51f23215960a16bee54e4abd09612c2012c6c716431 +9e0761070df35ed19fc09b8795d66db6021142b99f69f549561390b61ac0a134 +52f92a9c0c2cd9803077ca9a5ee88fe455cd8dd17c8fdba99638200c0a63014f +afe7313b2073231a6c2db4ee749b49f5f0af3105631a77f2b8b27fac0d430430 +c09fde16b28b07291c8e35392a755e05875a4d2f83fbfc91c03ab4cfe7d2e39f +0bf84907d5bf24b62c17fb6f5747c410caecdeb39b5fb2449e1b2cd61b9ddddc +dcf0d031d689f362e04e29bfc2ebdd079fce9fab6624285b2da6e2acff61d1e0 +25bee66822c1dfbac341f6a8b4e91f6463cd44ef24403823040810d4cb9d35c9 +ec06e17bc3d2377b92c9c738964797a4fa6dc13645a1b0b563957a373f62e997 +81e49365823b69f1dadd84a0fcb07e0e21e573664f5dc3f4e3cb3ef5b70b1bb3 +cf7a344c4bbd78d334131dc0e9c4cadb7e4ba1540a16fae268652cc81cda3ef0 +03b9528d2ab09525e80257938bbed9fc4c8f50308e1aefc368c1702557882134 +05a20b533ab2a7345f2f7ca4c51974b5594779fc173dd405e5361f70bc28aff4 +f77cceadf8138be43e272695706573b3476215d788365b2e0f9deb881cade818 +35d39e923d98550ed36dec9836896c784bd7c8eb38bf78c4f1490579cab16d0f +95e721bd96198c40d37ef9c9c1ffb4473800367c81dab7170cf70e92ff8e0d70 +448d2db0f09244f85d7f851218d16e80b06bcba0ec2fc7e53e4bd1ab6a55b565 +9e1d141ad0231a0c67767758cbce6dbc9fb40aa4b68af9b533cff1adf5b205e9 +2af66d2f3158c7da06854f881a9212bf6d4ed09dcc4d32c9e12ef3d8a1f9d28e +20760574c8c569d998e606d72eae71f8f705be80c383ef91deb71f4b0a45024e +9e0df9a3771307e7ce09b9efe4a4ea4a648d744f32816d614909b82c0c150e3b +47e3350d8dbb2012a9f92870c100e8e97cb16f519746c4f7e6afc3e0731cb05c +54ff346f3517b007cea9cebcdfc11b77e488b6ee86b1593a6b275f018ed7024e +8f7fe4882777cc53ca1c3af01b4321a584119eb431114c441a9f275c16e0adf6 +ea08117dc667e86f3d99bec9fe7a76f5d6f675cb9d95bfa9d5e0e5ccaa498655 +73dfddf6c6722d6a6d7db0220bbc823aa121fc75ed6be74b05549bfa527748af +e9fadef92b639b39dea8303926a63ac6aceb8ca83daf49c7a7018d0fb9e195a8 +26eaeb1b0e2b5e9f2e88422c74bfaf162b2e3345cc6283096f96bbb23c88a59b +73dad6fffd397e25e122c01760b9b18f7cfb4d8bc53bd5f49168f6a91b9ac25f +3b28a8cbd94de49716240e43c3354c508222c888c7e86b645302924b630b7b82 +c860ad68336e11d9f41cea92bc62f8016e1edb36240fce059743800bd31140fb +2843fef676d2600c81228dc2c4578f9de17004f22d0bb17ba4afc78f9adb22f2 +9b01be7e851cbe311b14a5f904d94451cc467fe903aac8d4ed5b127967f02e47 +1e583c1c8018d869e73d88dadb65bd6b69b294e7b32b12158a77e43005d59717 +31f6da73506a565ec7bfb116086a4499992e357c96b2449c53e0b21624c579b9 +d2dce5507ffef26cefcfb4efb3c4abbba4f4d7b02288a75b7243e12889ca08f6 +b89bddfc2b94a40086a8169c4790352668e0365640cdd9cfc8f8256a8cd67a76 +5d1f8f7562f1c0e7bcef9ba025b79c68bb0651b5d2039802f3489ca194e5b1f0 +b7928b410e299e00bd69691f656b64de1598ac680effa3a6cc1456fc625544d0 +02334e28175693b207b50e7ced14110afa8a8335fd22d00b2c4dc4f6d8a3480b +a290bf65e6d5fc843ccdb4e33f4b53ffa7d17363c324ce0fe91fc5c59bbf7fc3 +191b54c810f0d694f457e4575d8c306f113fd6443dbbb0a403abb36e04381355 +a1be53163c757fefe4bacf3b4c295cacf6d54e467a3a41bd1718bb5552efb0d9 +cd151dbb675f5f25e34d08db74c9d6f203e9c7fc0fbe662bc8054c2d9c11a4d6 +3c3f77e544b8d21eed0f4f39c68ce5804e3affaaa7188f53c193b040a44afc68 +dba6ef4a4eb5cc89ee9dfe2161a0f727da6fc65aa646837a20601af9b57dfc70 +639c0d1145a5cad6f18a21205d83b2ba1c48fd027efce0fed5477e5d89b3e4b8 +353223c07d88e11081a886257460c8ff1b7ad8c957a567e43994cc9f2f08dc2b +0f0871d1f2c12f7b76a486da9d194b53756fddce56321aba2c2c16c8740bb377 +343ffd487e7c1da166d6fe1acebaa1d2ef89fe0fc16d6ad108a3813a0f8c3206 +625a4e45280b2028a0ca85e806ef4d716664a377fd6e4e0bfaf8cfe2fce2e6b3 +4c4617317e3cfc2369faccc1b6bc5826a7dd92f8b06552fd6fcd2aebedcfb9dd +738c656ffd4e8e0a902c4da9cd7cb19316f0d4aa9f0aaa68c1353f7965abaa6b +ede77993572a017e4dee8a8abc14c6446eed4369385265e18a810afd32e8b3d8 +3029c3cec839a7edfefd05a692cb25b50f029a10710aeef7791307fed8ca4203 +325b6248633ee801e4c0d695f54fd0dbd6dddb61848daf92f74d9895d15923fa +3688c307fe659d4a178ac08afd7cdaa787abe55498aa537e8bd670a63088b3d0 +c4372ea5bfb4003d1b2208a7eaa48259ed48ed41ba5cc930639b5ecd89d45fd8 +4f335634ca6bbfe265197ae86b6ac3fb66dc2aefe8451f135fc8dc211dfe4baa +f1126e7188550c0e5d1da828d942ec48f1e0fc5963303ee3f28fa1e3b3c64ab7 +27b8cb0963d22c67584ac2acfe0afa74328958617d6ca0287b772101cb7091ba +52c6865d58ec4b891ff3e6489475463f9807f2b4f25e60af439e52a04c0f2601 +193dd2b43319b131d821dc554da172b1d36858838c6a88f1f8b747026a7e814e diff --git a/crates/pf-vkdecode/tests/gpu_parity.rs b/crates/pf-vkdecode/tests/gpu_parity.rs index 3b1724a8..b1a03d3f 100644 --- a/crates/pf-vkdecode/tests/gpu_parity.rs +++ b/crates/pf-vkdecode/tests/gpu_parity.rs @@ -46,6 +46,28 @@ //! prefix for a driver to mis-skip and no second framing to test (see //! `common::split_av1_aus`). Its absence is deliberate. //! +//! # The three legs that decode OUR OWN streams +//! +//! [`LOWDELAY_H264`], [`LOWDELAY_H265`] and [`LOWDELAY_AV1`] are not conformance +//! vectors — they are `punktfunk-host spike` output, vendored because a conformance +//! vector proves conformance to itself and the encoder we ship behind is a different +//! stream. Each is here for its own reason: +//! +//! * **H.264** caught a defect the vector is structurally blind to — 117 of its 120 +//! access units named one surface as both the decode target and a reference. +//! * **H.265** is EXEMPT from that defect for a structural reason, and an exemption +//! with no stream behind it is how the H.264 defect survived two milestones. +//! * **AV1** is neither: the vendored AV1 vector already aliases on 268 of its 274 +//! frames, so that class was covered. It is here because the vector is ONE TILE on +//! every frame while our encoder splits 4K into two tile rows, so every tile array +//! the conversions fill had only ever been exercised at index 0. +//! +//! All three are backed by a non-ignored CPU guard asserting the stream still has the +//! property it was vendored for. ⚠ And all three are FILES. A file fixture says +//! nothing about packetisation, reassembly or loss — which for AV1 is not a +//! hypothetical caveat but a recorded failure: this suite reported 250/250 throughout +//! the period the host was shipping only the first tile of every 4K frame. +//! //! # Why the AV1 leg exists at all //! //! Because until it did, the AV1 rung had no pixel evidence whatsoever. An @@ -116,6 +138,108 @@ const AV1_FRAME0: &[u8] = include_bytes!("data/test-25fps-av1.frame0.nv12"); const TEST_MAIN10_H265: &[u8] = include_bytes!("data/test-main10.h265"); const GOLDENS_MAIN10: &str = include_str!("data/test-main10.p010.sha256"); +/// **Our own host's H.264**, and the only stream here that is not a conformance +/// vector — vendored 2026-08-07 because the conformance vector is BLIND to the one +/// defect this rung had. +/// +/// `test-25fps.h264` reorders and carries a 7-frame DPB against 2 reference frames, +/// so a picture the sliding window unmarks is never evicted in the same access unit. +/// A punktfunk host emits low-delay IPPP with `max_num_reorder_frames = 0` and — this +/// is the part that matters — NVENC writes `max_num_ref_frames = 3` alongside +/// `max_dec_frame_buffering = 3`, a DPB exactly as deep as its reference count. 8.2.5's +/// window then unmarks the oldest reference in the very access unit whose C.4.5.3 bump +/// evicts it, and the conversion used to release that picture's slot before assigning +/// the setup one — so `pSetupReferenceSlot` and a reference named the same slot on +/// **117 of these 120 access units**. +/// +/// The vector passed 250/250 throughout. This is the stream that could not. +const LOWDELAY_H264: &[u8] = include_bytes!("data/lowdelay-640x480.h264"); +const GOLDENS_LOWDELAY: &str = include_str!("data/lowdelay-640x480.nv12.sha256"); + +/// The low-delay stream is 120 display frames at 640x480 (no conformance window — +/// both dimensions are macroblock-aligned, so the coded and display sizes agree). +const LOWDELAY_FRAME_COUNT: usize = 120; +const DISPLAY_LOWDELAY: (u32, u32) = (640, 480); + +/// **Our own host's HEVC**, the twin of [`LOWDELAY_H264`] — and the one vendored to +/// keep an exemption honest rather than to catch a defect. +/// +/// H.264 and AV1 both had to defer their slot releases past the decode op because +/// their planners snapshot the marked DPB BEFORE the marking that retires a picture. +/// `H265Planner` snapshots AFTER `decode_rps`, so an RPS-dropped picture is never in +/// the set `RefPicList`/`pReferenceSlots` is built from, and the HEVC conversions +/// still release inline. That argument is correct — and it was, until this stream, +/// backed by `test-25fps.h265` (which REORDERS, so it cannot reach the shape at all) +/// plus one throwaway measurement. +/// +/// This stream reaches the shape. `sps_max_dec_pic_buffering_minus1 = 4` against four +/// pictures marked in steady state, `sps_max_num_reorder_pics = 0`: 115 of its 120 +/// access units retire exactly one picture, and **all 115 of them would alias** if +/// the snapshot moved above `decode_rps`. Measured `removed ∩ dpb_refs` is 0 of 120, +/// so the exemption is a measurement on our own encoder's output rather than a +/// re-derivable argument. +/// +/// ⚠ On THIS rung that counterfactual is about the planner, not about +/// [`pf_vkdecode::plan_to_vk_h265`]: Vulkan's `pReferenceSlots` is spec-defined as the +/// slots the decode operation uses, so the conversion binds `plan.rps` — the three +/// current sets, which `decode_rps` itself derives — and never reads `dpb_refs` at +/// all. The DXVA rung is the one that binds the whole marked DPB (`RefPicList` is +/// spec-defined that way, and an RFI long-term anchor must survive in it), so it is +/// the rung a moved snapshot would actually alias on; +/// `pf_dxvadec::pic_h265`'s tests drive that counterfactual through the conversion. +/// What the leg below adds on this rung is the thing no HEVC leg here had: PIXELS +/// from our own encoder, under a DPB that evicts and reuses a slot on 115 of 120 +/// access units instead of a vector whose reordering keeps eviction slack. +/// +/// Provenance, the `punktfunk-host spike` command and the two-build ffmpeg +/// cross-check are in the golden file's header, as for the H.264 sibling. +const LOWDELAY_H265: &[u8] = include_bytes!("data/lowdelay-640x480.h265"); +const GOLDENS_LOWDELAY_H265: &str = include_str!("data/lowdelay-640x480-h265.nv12.sha256"); + +/// **Our own host's AV1**, and the only stream here with more than ONE TILE. +/// +/// The vendored AV1 vector already exercises the reference-slot aliasing shape (268 of +/// its 274 frames), so unlike the H.264 and H.265 siblings this is not vendored to +/// close that. It closes a different gap: no host-generated AV1 stream was tested at +/// pixel level anywhere, and our encoder's AV1 is structurally unlike the vector — +/// `RFI_DPB = 5` references, reference-frame invalidation, and at 4K a split encode +/// that puts **two tile rows in one frame**. +/// +/// 4K is not a size choice, it is the only shape that has the property. Measured on +/// .21, same command at four resolutions: 1280x720, 1920x1080 and 2560x1440 all give +/// `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with +/// both tiles in ONE Tile Group OBU. It is paid for with 60 frames instead of 120, +/// which lands at 261 KB — under both other low-delay fixtures. +/// +/// ⚠ It is a FILE, and a file is not the wire path. "250/250 bit-identical to +/// libavcodec" was true for AV1 throughout the period the host was shipping only the +/// first tile of every 4K frame: that number came from a vendored file while the +/// truncation lived in packetisation. This fixture gives the multi-tile shape pixel +/// coverage on the DECODE rungs and says nothing whatever about fragmentation, +/// reassembly or loss. The golden file's header says the same, at length. +const LOWDELAY_AV1: &[u8] = include_bytes!("data/lowdelay-3840x2160.ivf.av1"); +const GOLDENS_LOWDELAY_AV1: &str = include_str!("data/lowdelay-3840x2160-av1.nv12.sha256"); + +/// The low-delay AV1 stream's temporal units, DISPLAYED frames and render region. +/// +/// Units and frames are two constants holding 60 rather than one, and that is +/// deliberate: for the vendored vector they are 250 and 250 while the CODED count is +/// 274, and a leg that derived one from the other would be asserting AV1's frame +/// accounting instead of measuring it. +const LOWDELAY_AV1_UNIT_COUNT: usize = 60; +const LOWDELAY_AV1_FRAME_COUNT: usize = 60; +const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160); + +/// The HEVC low-delay stream's own frame count and display region. +/// +/// Deliberately NOT shared with [`LOWDELAY_FRAME_COUNT`]/[`DISPLAY_LOWDELAY`] even +/// though the two fixtures agree today: they are separate files from separate +/// encoder configurations, and one regenerated at another size must fail on its own +/// leg rather than silently redefine the other's geometry. Same reason +/// [`DISPLAY_H264`] and [`DISPLAY_H265`] are two constants holding 320x240. +const LOWDELAY_H265_FRAME_COUNT: usize = 120; +const DISPLAY_LOWDELAY_H265: (u32, u32) = (640, 480); + /// The Main 10 vector is 50 display frames. const MAIN10_FRAME_COUNT: usize = 50; @@ -690,6 +814,18 @@ fn assert_bit_identical(hashes: &[String], goldens: &[&str], codec: &str) { /// information — and running one body twice is what makes that an equality /// rather than two similar-looking assertions that could drift apart. fn h264_parity_run(aus: &[&[u8]], label: &str) { + h264_parity_run_against(aus, label, GOLDENS_H264, FRAME_COUNT, DISPLAY_H264); +} + +/// [`h264_parity_run`] with its stream's own goldens and geometry, for the legs that +/// do not decode the vendored vector. +fn h264_parity_run_against( + aus: &[&[u8]], + label: &str, + goldens: &'static str, + frame_count: usize, + display: (u32, u32), +) { // 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(); @@ -698,10 +834,10 @@ fn h264_parity_run(aus: &[&[u8]], label: &str) { // images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal. std::env::set_var("PF_VKD_TEST_READBACK", "1"); - let goldens = golden_hashes(GOLDENS_H264); + let goldens = golden_hashes(goldens); assert_eq!( goldens.len(), - FRAME_COUNT, + frame_count, "the golden file carries one hash per libavcodec frame" ); @@ -729,7 +865,7 @@ fn h264_parity_run(aus: &[&[u8]], label: &str) { setup.pd, &setup.device, setup.graphics_qf, - DISPLAY_H264, + display, EXPECTED_FORMAT, ) }; @@ -770,6 +906,28 @@ fn h264_four_byte_start_codes_decode_bit_identically() { ); } +/// The leg the vendored vector cannot be: **our own host's low-delay H.264**. +/// +/// The vector above passed 250/250 on every driver in the fleet while this rung +/// named one DPB slot as both `pSetupReferenceSlot` and a reference on 117 of the +/// 120 access units below — the shape it simply never produces (see +/// [`LOWDELAY_H264`]). Both of this rung's DPB modes take it badly and neither +/// loudly: DISTINCT hands the aliased reference the same array layer the setup +/// writes; COINCIDE finds no bound image for it, drops it from `pReferenceSlots` and +/// `trace!`s. So a leg that decodes what we actually ship is not redundant with the +/// conformance leg, it is the only one that can see this class at all. +#[test] +#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"] +fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() { + h264_parity_run_against( + &common::split_h264_aus(LOWDELAY_H264), + "H.264 (low-delay host stream)", + GOLDENS_LOWDELAY, + LOWDELAY_FRAME_COUNT, + DISPLAY_LOWDELAY, + ); +} + /// The H.265 twin of [`h264_parity_run`]; see its docs for why the AUs are a /// parameter. fn h265_parity_run( @@ -875,6 +1033,39 @@ fn main10_every_frame_hashes_bit_identical_to_libavcodec() { ); } +/// The HEVC twin of [`low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec`]: +/// **our own host's HEVC**, in the shape the vendored vector cannot produce. +/// +/// Its job is the opposite of the H.264 leg's. That one exists because the rung was +/// broken and only this stream shape could show it. This one exists because until now +/// no HEVC leg anywhere had decoded a single frame our own encoder produced: both +/// existing legs run vendored vectors, and the H.264 sibling is the standing proof +/// that a vector's silence about a stream shape is not evidence. +/// +/// What it exercises that `h265_every_frame_hashes_bit_identical_to_libavcodec` does +/// not: a five-picture DPB with four references marked and no reordering, so the +/// `SlotMap` retires and reissues a slot on 115 of the 120 access units, back to back, +/// with the decode target taking the slot freed in the same access unit. The vector +/// reorders, which keeps that eviction slack and never puts the two together. +/// +/// It is NOT the leg that would catch a moved `dpb_snapshot()` — see [`LOWDELAY_H265`] +/// for why that lands on the DXVA rung instead, and +/// [`the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable`] +/// for the guard that keeps the planner property itself pinned, on CPU, in ordinary CI. +#[test] +#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"] +fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() { + h265_parity_run( + &common::split_h265_aus(LOWDELAY_H265), + GOLDENS_LOWDELAY_H265, + LOWDELAY_H265_FRAME_COUNT, + 0, + EXPECTED_FORMAT, + DISPLAY_LOWDELAY_H265, + "H.265 (low-delay host stream)", + ); +} + /// The HEVC leg of the production prefix form — the one that would have caught /// the shipped defect. See [`h264_four_byte_start_codes_decode_bit_identically`]. #[test] @@ -903,24 +1094,52 @@ fn h265_four_byte_start_codes_decode_bit_identically() { /// `show_existing_frame`) is the point at which this should grow the same parameters /// the H.265 body carries — not before. fn av1_parity_run(aus: &[&[u8]], label: &str) { + av1_parity_run_against( + aus, + label, + GOLDENS_AV1, + "data/test-25fps-av1.nv12.sha256", + FRAME_COUNT, + FRAME_COUNT, + DISPLAY_AV1, + ); +} + +/// [`av1_parity_run`] with its stream's own goldens and geometry, for the leg that +/// does not decode the vendored vector. +/// +/// `units` and `frames` are SEPARATE parameters and must stay so. They are equal for +/// the low-delay host stream (one shown frame per temporal unit) and unequal for the +/// vendored vector only in the sense that its 250 units carry 274 coded frames of +/// which 250 are shown — deriving either from the other is exactly the assumption +/// AV1 punishes. +fn av1_parity_run_against( + aus: &[&[u8]], + label: &str, + goldens_file: &'static str, + goldens_path: &str, + units: usize, + frames: usize, + display: (u32, u32), +) { // As the other legs: 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(); std::env::set_var("PF_VKD_TEST_READBACK", "1"); - let goldens = golden_hashes(GOLDENS_AV1); + let goldens = golden_hashes(goldens_file); // Non-vacuity, before any hardware is touched: the right number of entries, all // real digests, all distinct (see the helper's docs — a frozen-frame decoder // must not be able to pass this leg). - assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps-av1.nv12.sha256"); + assert_goldens_are_a_real_set(&goldens, frames, goldens_path); // …and the leg must actually be fed something. An IVF whose packets failed to // parse would hand `collect_hashes` an empty AU list, which delivers no frames // and would then fail as a frame-count mismatch that reads like a decoder defect. assert_eq!( aus.len(), - FRAME_COUNT, - "{label}: the vector must split into {FRAME_COUNT} temporal units" + units, + "{label}: the stream must split into {units} temporal units" ); let setup = common::bring_up(&common::Request { @@ -957,7 +1176,7 @@ fn av1_parity_run(aus: &[&[u8]], label: &str) { setup.pd, &setup.device, setup.graphics_qf, - DISPLAY_AV1, + display, EXPECTED_FORMAT, ) }; @@ -1001,6 +1220,33 @@ fn av1_every_frame_hashes_bit_identical_to_libavcodec() { av1_parity_run(&common::split_av1_aus(common::TEST_25FPS_AV1), "AV1"); } +/// **Our own host's AV1, at the only resolution where it emits more than one tile.** +/// +/// The leg above proves the conversion against a vector with `tile_cols = tile_rows +/// = 1` on every one of its 274 frames, so every tile-info field it exercises is the +/// degenerate case: one `width_in_sbs_minus_1`, one `height_in_sbs_minus_1`, one +/// `context_update_tile_id`, `TileCols = TileRows = 1`. This stream carries +/// `tile_rows = 2` with `height_in_sbs_minus_1 = [16, 16]` on all 60 frames, and both +/// tiles arrive in a single Tile Group OBU — so a conversion that got the tile arrays, +/// the per-tile sizing or the tile-group range wrong would decode the vector perfectly +/// and this stream visibly (see [`LOWDELAY_AV1`]). +/// +/// It is also 4K, which no other parity leg in this program is: the readback moves +/// 12,441,600 bytes per frame instead of 115,200. +#[test] +#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"] +fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() { + av1_parity_run_against( + &common::split_av1_aus(LOWDELAY_AV1), + "AV1 (low-delay host stream, 4K two-tile)", + GOLDENS_LOWDELAY_AV1, + "data/lowdelay-3840x2160-av1.nv12.sha256", + LOWDELAY_AV1_UNIT_COUNT, + LOWDELAY_AV1_FRAME_COUNT, + DISPLAY_LOWDELAY_AV1, + ); +} + /// Frame 0's pixels against libavcodec's, byte for byte — the diagnostic leg. /// /// [`av1_every_frame_hashes_bit_identical_to_libavcodec`] is the verdict; this is @@ -1598,6 +1844,415 @@ fn h264_goldens_and_au_split_agree_with_the_planner() { ); } +/// The low-delay stream's own CPU guard, plus the property that makes it worth +/// vendoring at all. +/// +/// The goldens/AU/output agreement is the same three-way check +/// [`h264_goldens_and_au_split_agree_with_the_planner`] does. What is extra here is +/// the last assertion: this stream must actually REACH the aliasing precondition — +/// a picture removed by the same access unit whose `dpb_refs` still names it — on +/// nearly every access unit. If a re-generation ever produced a stream that did not, +/// the GPU leg above would still pass 120/120 while proving nothing the vendored +/// vector does not already prove, and nothing else would say so. +#[test] +fn the_low_delay_stream_agrees_with_its_goldens_and_still_exercises_the_aliasing_shape() { + use pf_bitstream::h264::H264Planner; + + let goldens = golden_hashes(GOLDENS_LOWDELAY); + assert_goldens_are_a_real_set( + &goldens, + LOWDELAY_FRAME_COUNT, + "data/lowdelay-640x480.nv12.sha256", + ); + + let aus = common::split_h264_aus(LOWDELAY_H264); + assert_eq!(aus.len(), LOWDELAY_FRAME_COUNT); + + let mut planner = H264Planner::new(); + let mut outputs = 0usize; + let mut both = 0usize; + let mut first_sps = None; + for (index, au) in aus.iter().enumerate() { + let plan = planner + .plan_au(au) + .unwrap_or_else(|e| panic!("AU {index}: the low-delay stream must plan, got {e:?}")); + outputs += plan.dpb.outputs.len(); + both += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); + first_sps.get_or_insert(( + plan.sps.max_num_ref_frames, + plan.picture.max_dpb_frames, + plan.sps.vui_parameters.max_num_reorder_frames, + )); + } + outputs += planner.flush().outputs.len(); + assert_eq!( + outputs, + goldens.len(), + "the planner outputs {outputs} pictures but the goldens carry {} hashes", + goldens.len() + ); + + // The three SPS facts that make the shape reachable, pinned so a regenerated + // stream from a different encoder cannot quietly stop being low-delay. + assert_eq!( + first_sps, + Some((3, 3, 0)), + "max_num_ref_frames, DPB depth and max_num_reorder_frames — a DPB exactly as \ + deep as the reference count, with no reordering, is what puts the unmarking \ + and the eviction in one access unit" + ); + assert_eq!( + both, 117, + "the stream must still remove pictures its own reference lists name — that is \ + the ONLY reason it is vendored, and without it the GPU leg is a duplicate of \ + the conformance one" + ); +} + +/// The HEVC low-delay stream's CPU guard — the twin of the H.264 one above, with the +/// extra assertion HEVC needs and H.264 does not. +/// +/// H.264's guard pins that the stream still ALIASES (117 of 120), because its GPU leg +/// exists to catch a defect. HEVC's leg exists to keep an exemption from rotting, so +/// pinning `both == 0` alone would be exactly the vacuous check `fd6241a2` called out: +/// zero is also what a stream that never removes anything reports, and what a stream +/// that reorders reports. So this pins three numbers instead: +/// +/// - **115 access units remove a picture** — the stream reaches the DPB pressure at all; +/// - **0 of them intersect `dpb_refs`** — the exemption, measured; +/// - **115 of them WOULD intersect** a snapshot taken before `decode_rps`. +/// +/// The third is what makes the second worth having. `pre_rps_marked(N)` is exact rather +/// than approximate: `begin_picture` runs `decode_rps` → `update_dpb_before_decoding` → +/// `dpb_snapshot`, and the only thing that happens between AU N-1's snapshot and AU N's +/// `decode_rps` is `finish_picture(N-1)` storing its picture marked short-term. So the +/// marked set AU N's RPS sees is exactly `dpb_refs(N-1) ∪ {stored(N-1)}`, which is what +/// `dpb_snapshot()` would have returned from the other side of that call. +#[test] +fn the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable() { + use pf_bitstream::h265::H265Planner; + + let goldens = golden_hashes(GOLDENS_LOWDELAY_H265); + assert_goldens_are_a_real_set( + &goldens, + LOWDELAY_H265_FRAME_COUNT, + "data/lowdelay-640x480-h265.nv12.sha256", + ); + + let aus = common::split_h265_aus(LOWDELAY_H265); + assert_eq!(aus.len(), LOWDELAY_H265_FRAME_COUNT); + + let mut planner = H265Planner::new(); + let mut outputs = 0usize; + let mut iraps = 0usize; + let mut with_removals = 0usize; + let mut both = 0usize; + let mut would_alias = 0usize; + let mut first_sps = None; + // The marked DPB as AU N's `decode_rps` finds it: AU N-1's snapshot plus the + // picture AU N-1 stored. See the doc comment for why this is exact. + let mut pre_rps_marked: Vec = Vec::new(); + for (index, au) in aus.iter().enumerate() { + let plan = planner.plan_au(au).unwrap_or_else(|e| { + panic!("AU {index}: the low-delay HEVC stream must plan, got {e:?}") + }); + outputs += plan.dpb.outputs.len(); + iraps += usize::from(plan.picture.is_irap); + if !plan.dpb.removed.is_empty() { + with_removals += 1; + } + both += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); + would_alias += plan + .dpb + .removed + .iter() + .filter(|id| pre_rps_marked.contains(id)) + .count(); + + // The picture shape both HEVC legs hard-code: `probe_stream_support(1, 0)` + // and an NV12 pool. Fail here, on CPU, rather than as a confusing + // hardware-only refusal. + assert_eq!( + ( + plan.picture.chroma_format_idc, + plan.picture.bit_depth_luma_minus8 + ), + (1, 0), + "AU {index}: the low-delay HEVC stream must stay Main 4:2:0 8-bit" + ); + if index == 0 { + assert!(plan.picture.is_idr, "the stream opens with an IDR"); + assert_eq!( + (plan.picture.coded_width, plan.picture.coded_height), + DISPLAY_LOWDELAY_H265, + "the stream is 640x480" + ); + 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_LOWDELAY_H265.0, DISPLAY_LOWDELAY_H265.1), + "640 and 480 are both multiples of MinCbSizeY, so there is no \ + conformance window and the coded size IS what the goldens hashed" + ); + } + first_sps.get_or_insert(( + plan.picture.max_dpb_frames, + plan.sps.max_num_reorder_pics[usize::from(plan.sps.max_sub_layers_minus1)], + )); + + pre_rps_marked = plan.dpb_refs.iter().map(|r| r.id).collect(); + if let Some(id) = plan.dpb.stored { + assert!( + plan.picture.is_reference, + "AU {index}: every picture of this stream is a reference — a \ + sub-layer non-reference picture would break the pre-RPS \ + reconstruction below" + ); + pre_rps_marked.push(id); + } + } + outputs += planner.flush().outputs.len(); + + assert_eq!( + outputs, + goldens.len(), + "the planner outputs {outputs} pictures but the goldens carry {} hashes", + goldens.len() + ); + assert_eq!( + iraps, 1, + "the stream holds exactly one IRAP (the opening IDR); a CRA/BLA would make \ + RASL skips reachable and the expected frame count needs rederiving" + ); + assert_eq!( + first_sps, + Some((5, 0)), + "DPB depth and sps_max_num_reorder_pics — a five-picture DPB against the four \ + pictures 8.3.2 keeps marked, with no reordering, is what puts an RPS drop and \ + the eviction it causes in one access unit" + ); + + assert_eq!( + with_removals, 115, + "the stream must still retire a picture on nearly every access unit; without \ + that the two numbers below are both trivially zero" + ); + assert_eq!( + both, 0, + "{both} picture(s) are in an access unit's own marked DPB AND removed by it. \ + That is the H.264/AV1 aliasing precondition, and HEVC is supposed to be \ + structurally incapable of it — `H265Planner`'s snapshot has moved ahead of \ + `decode_rps`. Restore the ordering, or give the HEVC conversions the \ + `release_after_decode` deferral the other two carry; do NOT relax this number" + ); + assert_eq!( + would_alias, 115, + "the fixture must stay CAPABLE of exposing the defect it is here to rule out. \ + A regenerated stream that reordered, or that carried a DPB deeper than its \ + reference count, would report 0 here — and the zero above would then prove \ + nothing at all, exactly as `test-25fps.h264` proved nothing for two milestones" + ); +} + +/// The AV1 low-delay stream's CPU guard, and the property it was vendored for: **more +/// than one tile**. +/// +/// A regenerated fixture could lose that in two silent ways — a re-run at a lower +/// resolution (1440p and below are single-tile on this encoder) or a driver/encoder +/// change that stopped splitting — and in both cases the GPU leg would go on passing +/// 60/60 while duplicating what the vendored vector already covers. So the tile shape +/// is asserted per frame, not sampled. +/// +/// It also pins AV1's frame accounting explicitly rather than by derivation. The +/// vendored vector is 250 units / 274 coded / 24 hidden / 250 shown; this stream is +/// 60 / 60 / 0 / 60. Neither is the general case, and a leg that assumed either would +/// break on the other for reasons that look like a decoder defect. +#[test] +fn the_low_delay_av1_stream_agrees_with_its_goldens_and_still_carries_two_tiles() { + use pf_bitstream::av1::Av1Planner; + + let goldens = golden_hashes(GOLDENS_LOWDELAY_AV1); + assert_goldens_are_a_real_set( + &goldens, + LOWDELAY_AV1_FRAME_COUNT, + "data/lowdelay-3840x2160-av1.nv12.sha256", + ); + + let aus = common::split_av1_aus(LOWDELAY_AV1); + assert_eq!( + aus.len(), + LOWDELAY_AV1_UNIT_COUNT, + "the low-delay AV1 stream is {LOWDELAY_AV1_UNIT_COUNT} temporal units" + ); + assert!( + aus.iter().all(|au| !au.is_empty()), + "no temporal unit is empty — an IVF reader returning empty packets would make \ + the parity leg decode nothing and blame the decoder" + ); + + let mut planner = Av1Planner::new(); + let mut outputs = 0usize; + let mut coded_frames = 0usize; + let mut multi_frame_units = 0usize; + let mut hidden = 0usize; + let mut show_existing = 0usize; + let mut keys = 0usize; + let mut with_removals = 0usize; + let mut aliasing_shape = 0usize; + for (index, au) in aus.iter().enumerate() { + let plans = planner.plan_au(au).unwrap_or_else(|e| { + panic!("temporal unit {index}: the low-delay stream must plan, got {e:?}") + }); + if plans.len() > 1 { + multi_frame_units += 1; + } + for plan in &plans { + coded_frames += 1; + outputs += plan.dpb.outputs.len(); + keys += usize::from(plan.picture.is_key); + hidden += usize::from(!plan.picture.show_frame); + if plan.dpb.stored.is_none() { + show_existing += 1; + } + assert!( + plan.warnings.is_empty(), + "temporal unit {index}: a clean stream plans without warnings, got {:?}", + plan.warnings + ); + + // THE PROPERTY. Two tile ROWS, one tile COLUMN, both tiles in a single + // Tile Group OBU — the 4K split-encode shape, on every frame including + // the key frame. + let tile = &plan.header.tile_info; + assert_eq!( + (tile.tile_cols, tile.tile_rows), + (1, 2), + "frame {coded_frames} (unit {index}): this fixture exists because our \ + encoder emits TWO TILE ROWS at 4K. A single-tile stream here means it \ + was regenerated at a lower resolution (1440p and below measured \ + single-tile) or the encoder stopped splitting — either way the GPU leg \ + below is now a duplicate of the vendored vector's and this fixture's \ + 260 KB buys nothing. Regenerate at 3840x2160; do NOT relax this" + ); + assert_eq!( + ( + tile.width_in_sbs_minus_1[0], + tile.height_in_sbs_minus_1[0], + tile.height_in_sbs_minus_1[1], + ), + (59, 16, 16), + "frame {coded_frames}: the per-tile superblock sizing the conversions \ + copy into their tile arrays" + ); + assert_eq!( + plan.tiles.len(), + 1, + "frame {coded_frames}: both tiles ride in ONE Tile Group OBU" + ); + assert_eq!( + (plan.tiles[0].tg_start, plan.tiles[0].tg_end), + (0, 1), + "frame {coded_frames}: the single tile group covers tiles 0..=1 — a \ + range of 0..=0 is the truncation shape the host once shipped" + ); + + // The picture shape both AV1 legs hard-code (`probe_stream_support(1, 8, + // false)` plus an NV12 pool). Film grain especially: it is part of the + // Vulkan decode PROFILE, so a grain-bearing stream is a different device + // requirement, not merely different pixels. + assert_eq!( + ( + plan.picture.chroma_format_idc, + plan.picture.bit_depth, + plan.sequence.film_grain_params_present, + ), + (1, 8, false), + "frame {coded_frames}: Main 4:2:0 8-bit, no film grain" + ); + if coded_frames == 1 { + assert!(plan.picture.is_key, "the stream opens on a key frame"); + assert_eq!( + (plan.picture.render_width, plan.picture.render_height), + DISPLAY_LOWDELAY_AV1, + "the render region the readback crops to and the goldens hash" + ); + assert_eq!( + (plan.picture.upscaled_width, plan.picture.frame_height), + DISPLAY_LOWDELAY_AV1, + "no superres and no AV1 conformance-window equivalent — the coded \ + picture IS the render region" + ); + } + + if !plan.dpb.removed.is_empty() { + with_removals += 1; + } + aliasing_shape += plan + .dpb + .removed + .iter() + .filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id)) + .count(); + } + } + + // AV1's frame accounting, pinned rather than derived. This stream is the SIMPLE + // shape — one shown frame per temporal unit — which is exactly why it must be + // stated: the vendored vector is not, and a leg that learned its habits from one + // of them silently mis-counts the other. + assert_eq!( + ( + coded_frames, + outputs, + multi_frame_units, + hidden, + show_existing, + keys + ), + ( + LOWDELAY_AV1_FRAME_COUNT, + LOWDELAY_AV1_FRAME_COUNT, + 0, + 0, + 0, + 1 + ), + "coded / displayed / multi-frame units / hidden / show_existing / key frames — \ + our host emits one shown frame per temporal unit and one key frame at the \ + head, against the vendored vector's 274 / 250 / 24 / 24 / 0 / 1" + ); + assert_eq!( + outputs, + goldens.len(), + "the planner outputs {outputs} pictures but the goldens carry {}", + goldens.len() + ); + + // Not the reason this fixture exists — the vendored vector already aliases on 268 + // of its 274 frames — but recorded so a regeneration cannot quietly drop below the + // vector's coverage while claiming to be the host-shaped stream. + assert_eq!( + (with_removals, aliasing_shape), + (55, 55), + "55 of the 60 frames displace a reference they still name, which is the \ + precondition `release_after_decode` exists for" + ); +} + #[test] fn the_main10_vector_is_ten_bit_and_agrees_with_its_goldens() { use pf_bitstream::h265::H265Planner;