From 9c10b97e31f67195eb7d72f06e3d576b1d9f14ea Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 09:46:53 +0200 Subject: [PATCH] fix(client): M5's DXVA bytes now answer to libavcodec's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native D3D11VA rung was committed with zero hardware evidence and, more to the point, zero evidence of any kind: every claim it made about the DXVA structures rested on reading the specification and reading libavcodec, and reading is exactly the method that produced the four defects the last review found. Three of those lived where a smoke test cannot look — in the buffer descriptors — so a passing session would have proved nothing about them. So this compares our submission against libavcodec's, byte for byte, on the same access units of the same two vendored vectors. The reference side comes from an FFmpeg n8.1 cross-built for Windows with the DXVA paths instrumented (the recipe is in the harness docs); the comparison covers picture parameters, quantisation matrices AND the buffer descriptors, 250 AUs per codec: H.264 / HEVC picture parameters 250 AUs, no undocumented divergence H.264 / HEVC quantisation matrices 250 AUs, no undocumented divergence H.264 / HEVC buffer descriptors 250 AUs, no undocumented divergence It found a real defect immediately. The DXVA short slice record is TEN bytes — dxva.h packs these bitstream-layout structs to a byte — and this crate declared it `repr(C)`, which pads {u32,u32,u16} to twelve. libavcodec's own descriptors say so twice over: 20 bytes of slice control for a two-slice H.264 picture, 10 for a one-slice HEVC one. Record 0 survives either way (its fields sit at 0/4/8 regardless), so the mistake is invisible on a single-slice stream and displaces every later record by two bytes on a multi-slice one — which punktfunk hosts emit. Both records are now `repr(C, packed)`, and the HEVC slice-control test grew a second record because one record is the shape that hid this. The audit that followed matters more than the fix. Per-field offset asserts cannot see TAIL padding, which is what this was, so all six hand-declared structs now also assert that their size equals the last field's offset plus that field's own size. Under that rule the slice records were the only place packed and natural alignment disagree — 1040, 232, 224 and 1000 were right all along, and now provably rather than luckily. The module docs claimed `repr(C)` "reproduces MSVC's default packing exactly for that shape"; that was a guess wearing a proof's clothes, and it is gone. Two differences are documented rather than fixed, each with the argument for why it is inert. libavcodec seeds prev_poc_msb = 1 << 16 at every IDR, so its POCs are the specification's plus 65536 uniformly; every use a driver makes of those fields is a difference, and references match on FrameNumList, so the harness compares POCs relative to that constant and requires it to hold on every AU rather than importing a magic number into a derivation the Vulkan rung shares. And HEVC's loop_filter_across_tiles_enabled_flag is inferred 1 by 7.4.3.3.1 when the PPS codes no tiles while libav leaves it 0, with tiles disabled either way. Both ride a channel that always prints, and both are guarded by tests that synthesise the differences an allowance must NOT absorb — a documented divergence that swallows a real defect would be worse than no harness at all. Everything checkable without a capture is now a non-ignored test: the buffer set and order per codec, NumMBsInBuffer's codec asymmetry (mb_width*mb_height on H.264's bitstream and slice-control buffers, zero everywhere for HEVC), the three 7.4.5 scaling-list cases, contiguous slice records tiling DataSize, the 128-byte padding charged to the last record and no other. That is the part which would have caught the last round's defects with no hardware at all. --- crates/pf-dxvadec/src/descriptors.rs | 444 +++ crates/pf-dxvadec/src/dxva.rs | 108 +- crates/pf-dxvadec/src/lib.rs | 12 + crates/pf-dxvadec/src/pic.rs | 20 +- crates/pf-dxvadec/src/pic_h265.rs | 31 +- .../tests/libav_picparams_parity.rs | 3255 ++++++++++++++++- 6 files changed, 3677 insertions(+), 193 deletions(-) create mode 100644 crates/pf-dxvadec/src/descriptors.rs diff --git a/crates/pf-dxvadec/src/descriptors.rs b/crates/pf-dxvadec/src/descriptors.rs new file mode 100644 index 00000000..ad2f1b58 --- /dev/null +++ b/crates/pf-dxvadec/src/descriptors.rs @@ -0,0 +1,444 @@ +//! The buffer DESCRIPTORS one `ID3D11VideoContext::SubmitDecoderBuffers` call +//! carries: which buffers are in the set at all, and the four +//! `D3D11_VIDEO_DECODER_BUFFER_DESC` fields whose values are a DECISION rather +//! than a pointer the driver handed back. +//! +//! # Why this is a module of its own +//! +//! Review 13 found four defects in this backend. **Two of the three structural +//! ones lived here rather than in the picture parameters**: an HEVC +//! quantization-matrix buffer submitted unconditionally (so a driver was handed a +//! matrix of zeros on every stream that disables scaling lists), and a +//! `NumMBsInBuffer` left at 0 where libavcodec's H.264 path writes +//! `mb_width * mb_height` — on the exact call (`SubmitDecoderBuffers`) that this +//! codebase has already seen an Intel driver reject a hand-built variant on. +//! +//! Neither is visible in the picture parameters, neither is visible in a smoke +//! test, and — before this module — neither was visible to any gate this program +//! runs, because the descriptors were built inside `cfg(windows)` code that no CI +//! leg compiles. That is the whole reason the values live here: a descriptor set +//! is a pure function of the conversion's output plus the packer's output, so it +//! can be asserted on any host, on every leg, over every AU of the vendored +//! vectors. +//! +//! # ⚠ The Windows layer still builds its own — rewire it +//! +//! `pf-client-core`'s `video_d3d11_native.rs` (`fill_and_submit` + its private +//! `buffer_desc`) constructs the same four descriptors itself. This module was +//! written to be the single source of truth for them, and that file should be +//! rewired to call [`descriptors_h264`] / [`descriptors_h265`] and translate the +//! result into `D3D11_VIDEO_DECODER_BUFFER_DESC` field for field. Until it is, +//! the two must be read together: this module is the SPEC and the tests are its +//! proof, and a divergence between them is a defect in the Windows file. The +//! ordering, the values and the presence rule below are exactly what that file +//! does today, transcribed — not a new invention. +//! +//! # The values, and where each comes from +//! +//! `CompressedBufferType` (D3D11's `BufferType`) code points, from windows-rs at +//! the workspace's pinned rev (`acb5a1a`, +//! `crates/libs/windows/src/Windows/Win32/d3d11/mod.rs`) — the same numbers +//! DXVA2's `DXVA2_*BufferType` enumeration uses: +//! +//! | buffer | code point | +//! |---|---| +//! | picture parameters | 0 | +//! | inverse quantization matrix | 4 | +//! | slice control | 5 | +//! | bitstream | 6 | +//! +//! **Order**: picture parameters, quantization matrices, bitstream, slice +//! control. libavcodec's `ff_dxva2_common_end_frame` fills its four-entry +//! descriptor array in exactly that order and submits the array as filled; a +//! driver is entitled to care, and matching the path every Windows player +//! exercises costs nothing. +//! +//! **`DataOffset`** is 0 on every buffer, for both sides: each buffer is written +//! from its own mapping's byte 0. (libavcodec `memset`s the descriptor and never +//! writes the field.) +//! +//! **`DataSize`** is the number of bytes actually written: the whole +//! hand-declared struct for the picture parameters and the quantization matrices, +//! the packer's PADDED size for the bitstream ([`crate::pack::Packed::data_size`], +//! a multiple of [`crate::dxva::BITSTREAM_ALIGN`]), and `slices * +//! size_of::()` for the slice control — **ten** bytes per +//! record, not twelve. That number is a measured fact rather than a derivation +//! (`dxva.rs`'s alignment section carries the measurement), and the slice-control +//! `DataSize` is where it is observable from outside: 20 bytes for a two-slice +//! H.264 picture, 10 for a one-segment HEVC one. +//! +//! **`NumMBsInBuffer` is codec-ASYMMETRIC, and that is not an accident to be +//! tidied up:** +//! +//! * H.264 — `mb_width * mb_height` on the BITSTREAM and SLICE_CONTROL +//! descriptors ([`crate::pic::DecodePlanDxva::mb_count`]); +//! * HEVC — 0 on the same two. HEVC has no macroblocks and the field has no CTB +//! spelling; +//! * picture parameters and quantization matrices — 0 in both codecs. +//! +//! That asymmetry is libavcodec's, read out of an **FFmpeg n8.1** tree: +//! `dxva2_h264.c:307` computes `const unsigned mb_count = h->mb_width * +//! h->mb_height` and writes it on the bitstream descriptor (`:412` D3D11, `:425` +//! DXVA2) and passes it for the slice-control commit (`:440-442`); +//! `dxva2_hevc.c` writes a literal 0 in the same three places (`:338`, `:349`, +//! `:359-361`); and `dxva2.c` passes a literal 0 for the two parameter buffers. +//! Setting a CTB count on the HEVC path would be a fresh divergence in the other +//! direction, which is why it is spelled out here rather than left to symmetry. +//! +//! # Presence: the quantization matrix is codec-asymmetric too +//! +//! * **H.264: always submitted.** `dxva2_h264.c:513-516` passes `&ctx_pic->qm` +//! with `sizeof(qm)` unconditionally, and the PPS's lists are always meaningful +//! (the vendored parser has already applied Table 7-2's fallback rules, so a PPS +//! that codes no matrix carries the SPS's or the flat default). +//! * **HEVC: submitted only when the sequence enables scaling lists.** +//! `dxva2_hevc.c:417` takes `int scale = ctx_pic->pp.dwCodingParamToolFlags & 1` +//! — bit 0 is `scaling_list_enabled_flag` — and `:423-426` passes `NULL`/0 when +//! it is clear; the generic layer then submits an IQ-matrix buffer only `if +//! (qm_size > 0)` (`dxva2.c` ~962), with `NumMBsInBuffer` 0. +//! [`crate::pic_h265::DecodePlanDxvaH265::qmatrix`] is `None` in exactly that +//! case, so presence here is `qmatrix.is_some()` and nothing else. Handing a +//! driver a matrix the picture parameters just told it to ignore is a bet on the +//! driver ignoring it too — and with the vendored parser leaving an uncoded list +//! all-zero, the losing side of that bet is every residual dequantizing to +//! nothing. +//! +//! ⚠ The flag test is NECESSARY but not SUFFICIENT. HEVC 7.4.5 says that with +//! `scaling_list_enabled_flag` set and NO scaling-list data in either parameter +//! set, the Table 7-5/7-6 DEFAULT lists apply. FFmpeg's parser seeds those +//! defaults; the vendored cros-codecs parser leaves an uncoded SPS's lists ALL +//! ZERO. So "submit iff the flag" is only half the rule, and the other half lives +//! in [`crate::pic_h265`]'s `quantization_matrices`, which reads the PPS's copy +//! (which that parser DOES default-fill) unless the SPS is the only side that +//! coded any. All three cases are named CPU tests — two in `pic_h265.rs` for the +//! contents, three in `tests/libav_picparams_parity.rs` for the submission fact. +//! +//! # Provenance +//! +//! The libavcodec file:line references above were read out of an FFmpeg n8.1 tree +//! by this work package's coordinator, not out of this repository — there is no +//! FFmpeg source in the worktree, so nothing here can verify them, and a capture is +//! the authority. The buffer ORDER is the one claim with no line reference: it is +//! what `video_d3d11_native.rs` already submits and what +//! `ff_dxva2_common_end_frame` fills its array in, and the harness's descriptor +//! comparison is what will confirm it. + +use std::mem::size_of; + +use crate::dxva::PicParamsH264; +use crate::dxva::PicParamsHevc; +use crate::dxva::QmatrixH264; +use crate::dxva::QmatrixHevc; +use crate::dxva::SliceH264Short; +use crate::dxva::SliceHevcShort; +use crate::pack::Packed; +use crate::pic::DecodePlanDxva; +use crate::pic_h265::DecodePlanDxvaH265; + +/// `D3D11_VIDEO_DECODER_BUFFER_PICTURE_PARAMETERS`. +pub const BUFFER_PICTURE_PARAMETERS: u32 = 0; +/// `D3D11_VIDEO_DECODER_BUFFER_INVERSE_QUANTIZATION_MATRIX`. +pub const BUFFER_INVERSE_QUANTIZATION_MATRIX: u32 = 4; +/// `D3D11_VIDEO_DECODER_BUFFER_SLICE_CONTROL`. +pub const BUFFER_SLICE_CONTROL: u32 = 5; +/// `D3D11_VIDEO_DECODER_BUFFER_BITSTREAM`. +pub const BUFFER_BITSTREAM: u32 = 6; + +/// One buffer of a submission, reduced to the fields a caller DECIDES. +/// +/// Deliberately not a `D3D11_VIDEO_DECODER_BUFFER_DESC`: that structure has +/// fourteen members, of which ten are either for a mode this backend does not use +/// (`BufferIndex`, `FirstMBaddress`, `Width`/`Height`/`Stride` — motion-compensation +/// buffers), or for protected content (`pIV`, `IVSize`, `PartialEncryption`, +/// `EncryptedBlockInfo`), or reserved. All ten are zero on every buffer this +/// backend submits, which the Windows layer expresses as `..Default::default()`; +/// the four here are the ones that carry a decision, and therefore the ones a +/// comparison against libavcodec is about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BufferDescriptor { + /// `BufferType` — one of this module's `BUFFER_*` code points. (The DXVA + /// specs and libavcodec's DXVA2 path call the same field + /// `CompressedBufferType`.) + pub buffer_type: u32, + /// `DataOffset` — 0 on every buffer of every submission (module docs). + pub data_offset: u32, + /// `DataSize` — bytes written into the driver's mapping. + pub data_size: u32, + /// `NumMBsInBuffer` — codec-asymmetric; see the module docs. + pub num_mbs_in_buffer: u32, +} + +impl BufferDescriptor { + /// A descriptor with `DataOffset` 0, which is the only value this backend + /// ever submits. + const fn new(buffer_type: u32, data_size: u32, num_mbs_in_buffer: u32) -> BufferDescriptor { + BufferDescriptor { + buffer_type, + data_offset: 0, + data_size, + num_mbs_in_buffer, + } + } +} + +/// The slice-control buffer's `DataSize`: `n` short-format records back to back, +/// exactly as [`crate::dxva::slice_bytes`] lays them out. +/// +/// Saturating rather than panicking on the (unreachable) overflow: a `u32` holds +/// 429 million ten-byte records, and an AU that produced more has already been +/// refused by the packer. +fn slice_control_size(record_size: usize, records: usize) -> u32 { + u32::try_from(record_size.saturating_mul(records)).unwrap_or(u32::MAX) +} + +/// The descriptor set of one H.264 submission, in libavcodec's order. +/// +/// Four buffers, always: the quantization matrices travel on every H.264 picture +/// (module docs). +pub fn descriptors_h264(plan: &DecodePlanDxva, packed: &Packed) -> Vec { + let mb_count = plan.mb_count; + vec![ + BufferDescriptor::new( + BUFFER_PICTURE_PARAMETERS, + size_of::() as u32, + 0, + ), + BufferDescriptor::new( + BUFFER_INVERSE_QUANTIZATION_MATRIX, + size_of::() as u32, + 0, + ), + BufferDescriptor::new(BUFFER_BITSTREAM, packed.data_size, mb_count), + BufferDescriptor::new( + BUFFER_SLICE_CONTROL, + slice_control_size(size_of::(), packed.records.len()), + mb_count, + ), + ] +} + +/// The descriptor set of one HEVC submission, in libavcodec's order. +/// +/// THREE buffers when the sequence disables scaling lists (which is every +/// punktfunk HEVC stream and the vendored vector with it), four when it enables +/// them — and `NumMBsInBuffer` is 0 on all of them (module docs). +pub fn descriptors_h265(plan: &DecodePlanDxvaH265, packed: &Packed) -> Vec { + let mut out = Vec::with_capacity(4); + out.push(BufferDescriptor::new( + BUFFER_PICTURE_PARAMETERS, + size_of::() as u32, + 0, + )); + if plan.qmatrix.is_some() { + out.push(BufferDescriptor::new( + BUFFER_INVERSE_QUANTIZATION_MATRIX, + size_of::() as u32, + 0, + )); + } + out.push(BufferDescriptor::new(BUFFER_BITSTREAM, packed.data_size, 0)); + out.push(BufferDescriptor::new( + BUFFER_SLICE_CONTROL, + slice_control_size(size_of::(), packed.records.len()), + 0, + )); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dxva::PicEntry; + use crate::pack::SliceRecord; + use crate::pic::DxvaRef; + + /// A conversion result with nothing in it but the two fields the descriptors + /// read. Built by hand rather than planned from a vector: this module's job is + /// the descriptor SET, and the whole-stream evidence (250 H.264 + 250 HEVC AUs + /// through the real planners) is in `tests/libav_picparams_parity.rs`. + fn h264_plan(mb_count: u32) -> DecodePlanDxva { + DecodePlanDxva { + pic_params: PicParamsH264::zeroed(), + qmatrix: QmatrixH264::zeroed(), + slice_ranges: Vec::new(), + setup_slot: 0, + setup_id: 1, + setup_is_reference: true, + refs: Vec::::new(), + mb_count, + } + } + + fn h265_plan(qmatrix: Option) -> DecodePlanDxvaH265 { + DecodePlanDxvaH265 { + pic_params: PicParamsHevc::zeroed(), + qmatrix, + slice_ranges: Vec::new(), + setup_slot: 0, + setup_id: 1, + setup_is_reference: true, + refs: Vec::new(), + } + } + + /// `n` slices packed into `data_size` bytes; the record contents do not matter + /// here, only how many there are. + fn packed(slices: usize, data_size: u32) -> Packed { + Packed { + records: (0..slices) + .map(|i| SliceRecord { + location: i as u32 * 64, + bytes: 64, + }) + .collect(), + data_size, + } + } + + #[test] + fn the_buffer_type_code_points_are_the_ones_windows_rs_declares() { + // From the workspace's pinned windows-rs rev (`acb5a1a`), + // `crates/libs/windows/src/Windows/Win32/d3d11/mod.rs`: + // D3D11_VIDEO_DECODER_BUFFER_PICTURE_PARAMETERS = 0, + // …_INVERSE_QUANTIZATION_MATRIX = 4, …_SLICE_CONTROL = 5, …_BITSTREAM = 6. + // Nothing else in this crate can catch a transposed pair, and a + // transposition would hand the driver a bitstream where it expects slice + // control. + assert_eq!(BUFFER_PICTURE_PARAMETERS, 0); + assert_eq!(BUFFER_INVERSE_QUANTIZATION_MATRIX, 4); + assert_eq!(BUFFER_SLICE_CONTROL, 5); + assert_eq!(BUFFER_BITSTREAM, 6); + } + + #[test] + fn an_h264_submission_carries_four_buffers_in_libavcodecs_order() { + let descs = descriptors_h264(&h264_plan(300), &packed(2, 512)); + assert_eq!( + descs.iter().map(|d| d.buffer_type).collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_INVERSE_QUANTIZATION_MATRIX, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ] + ); + assert_eq!(descs[0].data_size, 1040); + assert_eq!(descs[1].data_size, 224); + assert_eq!(descs[2].data_size, 512); + assert_eq!(descs[3].data_size, 2 * 10, "two ten-byte short records"); + } + + #[test] + fn only_the_h264_bitstream_and_slice_control_buffers_carry_a_macroblock_count() { + // Review 13's defect, in the smallest form that can express it: the field + // is 0 on the two parameter buffers and mb_width*mb_height on the two the + // hardware parses. + let descs = descriptors_h264(&h264_plan(300), &packed(1, 256)); + assert_eq!(descs[0].num_mbs_in_buffer, 0, "picture parameters"); + assert_eq!(descs[1].num_mbs_in_buffer, 0, "quantization matrices"); + assert_eq!(descs[2].num_mbs_in_buffer, 300, "bitstream"); + assert_eq!(descs[3].num_mbs_in_buffer, 300, "slice control"); + } + + #[test] + fn an_hevc_submission_omits_the_quantization_matrix_buffer_when_there_is_none() { + let descs = descriptors_h265(&h265_plan(None), &packed(1, 384)); + assert_eq!( + descs.iter().map(|d| d.buffer_type).collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ], + "a submission with no matrix must not carry an empty matrix buffer" + ); + assert!(descs + .iter() + .all(|d| d.buffer_type != BUFFER_INVERSE_QUANTIZATION_MATRIX)); + } + + #[test] + fn an_hevc_submission_carries_the_quantization_matrix_buffer_when_there_is_one() { + let descs = descriptors_h265(&h265_plan(Some(QmatrixHevc::zeroed())), &packed(3, 640)); + assert_eq!( + descs.iter().map(|d| d.buffer_type).collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_INVERSE_QUANTIZATION_MATRIX, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ] + ); + assert_eq!(descs[0].data_size, 232); + assert_eq!(descs[1].data_size, 1000); + assert_eq!(descs[2].data_size, 640); + assert_eq!(descs[3].data_size, 3 * 10, "three ten-byte short records"); + } + + #[test] + fn the_hevc_descriptors_carry_no_macroblock_count_at_all() { + // The asymmetry, asserted rather than assumed: libavcodec's HEVC path + // writes 0 where its H.264 path writes mb_width*mb_height, and a CTB count + // here would be a divergence in the other direction. + for descs in [ + descriptors_h265(&h265_plan(None), &packed(1, 256)), + descriptors_h265(&h265_plan(Some(QmatrixHevc::zeroed())), &packed(4, 1024)), + ] { + for desc in descs { + assert_eq!( + desc.num_mbs_in_buffer, 0, + "buffer type {} carries a macroblock count", + desc.buffer_type + ); + } + } + } + + #[test] + fn every_descriptor_starts_at_byte_zero_of_its_own_buffer() { + let h264 = descriptors_h264(&h264_plan(1), &packed(2, 256)); + let h265 = descriptors_h265(&h265_plan(Some(QmatrixHevc::zeroed())), &packed(2, 256)); + for desc in h264.into_iter().chain(h265) { + assert_eq!(desc.data_offset, 0); + } + } + + #[test] + fn the_slice_control_size_is_one_short_format_record_per_slice() { + // TEN bytes per record is the SHORT format, packed — measured against + // libavcodec on hardware, not derived from the field types (a `#[repr(C)]` + // `{u32, u32, u16}` would be twelve). The long format's record is an order of + // magnitude larger, so this size is also the check that the records match the + // `ConfigBitstreamRaw` this backend asks for. + assert_eq!(size_of::(), 10); + assert_eq!(size_of::(), 10); + for slices in [1usize, 2, 5, 68] { + let h264 = descriptors_h264(&h264_plan(1), &packed(slices, 4096)); + assert_eq!(h264[3].data_size, 10 * slices as u32); + let h265 = descriptors_h265(&h265_plan(None), &packed(slices, 4096)); + assert_eq!(h265[2].data_size, 10 * slices as u32); + } + } + + #[test] + fn a_reference_entry_in_the_plan_does_not_reach_the_descriptors() { + // A guard on the shape of this module rather than on a value: descriptors + // are a function of SIZES and the macroblock count, so nothing about the + // reference list may leak into them. (Also keeps `DxvaRef` in the test's + // vocabulary, so the plan built above stays a realistic one.) + let mut plan = h264_plan(300); + plan.refs.push(DxvaRef { + slot: 2, + id: 7, + is_long_term: true, + top_field_order_cnt: 4, + bottom_field_order_cnt: 4, + frame_num_or_lt_idx: 1, + }); + plan.pic_params.RefFrameList[0] = PicEntry::new(2, true); + assert_eq!( + descriptors_h264(&plan, &packed(1, 256)), + descriptors_h264(&h264_plan(300), &packed(1, 256)) + ); + } +} diff --git a/crates/pf-dxvadec/src/dxva.rs b/crates/pf-dxvadec/src/dxva.rs index 4de7789c..82b5ba61 100644 --- a/crates/pf-dxvadec/src/dxva.rs +++ b/crates/pf-dxvadec/src/dxva.rs @@ -41,12 +41,37 @@ //! The one unsafe in the crate is [`as_bytes`], and it is fenced behind a //! sealed trait that only these `#[repr(C)]` PODs implement. //! -//! # Alignment +//! # Alignment — and the one place natural alignment is WRONG //! -//! Every member is 1/2/4 bytes, so the natural alignment of all of these is 4 and -//! no member ever needs the x86-64 8-byte rules. `#[repr(C)]` reproduces MSVC's -//! default (`/Zp8`) packing exactly for that shape; the offset assertions are what -//! proves it rather than assumes it. +//! `dxva.h` declares these as wire-format structures under **1-byte packing**, not +//! under MSVC's default. For five of the six that is indistinguishable from natural +//! alignment, because every member happens to sit at a naturally-aligned offset and +//! every total is already a multiple of 4: `DXVA_PicParams_H264` is 1040, +//! `DXVA_PicParams_HEVC` 232, the two quantization matrices 224 and 1000 — all +//! confirmed against libavcodec's runtime `sizeof` in the n8.1 capture described in +//! `tests/libav_picparams_parity.rs`. +//! +//! The slice-control records are the exception and the reason this section exists. +//! `{UINT, UINT, USHORT}` is **10 bytes packed and 12 under natural alignment**, and +//! an earlier revision of this file declared them plain `#[repr(C)]` — asserting 12 +//! with "2 bytes tail padding" in the comment, which was a guess dressed as a proof. +//! Measured on hardware (RTX 4090, patched FFmpeg n8.1, both vendored vectors, 250 +//! AUs each): the H.264 slice-control buffer's `DataSize` is 20 on a stream with two +//! slices per picture, and the HEVC one's is 10 on a stream with one slice segment +//! per picture. Two codecs, two slice counts, one answer — 10. +//! +//! What the mistake costs, so it is never re-introduced: record 0's fields land at +//! 0/4/8 either way, so a SINGLE-slice stream decodes correctly and the two extra +//! bytes are trailing slop nobody reads. From the second record on, every field is +//! displaced by two bytes per preceding record, so the driver reads a slice offset +//! built from half of one field and half of the next. punktfunk hosts do emit +//! multi-slice streams. +//! +//! Hence: **the packed structs carry `#[repr(C, packed)]`** and the proofs below +//! pin `align_of` as well as `size_of`, plus — for every struct — that its size is +//! exactly its last member's offset plus that member's size, which is the assertion +//! that would have caught this one. Interior padding was already impossible (the +//! per-field offset asserts see it); it was TAIL padding that got in. //! //! Sources: the DXVA specifications "DirectX Video Acceleration Specification for //! H.264/AVC Decoding" (§4.2 `DXVA_PicParams_H264`, §4.4 `DXVA_Qmatrix_H264`, §4.6 @@ -62,6 +87,7 @@ // every generated Win32 struct. #![allow(non_snake_case)] +use std::mem::align_of; use std::mem::offset_of; use std::mem::size_of; @@ -388,16 +414,21 @@ impl QmatrixH264 { /// UINT BSNALunitDataLocation; /* 0 */ /// UINT SliceBytesInBuffer; /* 4 */ /// USHORT wBadSliceChopping; /* 8 */ -/// } DXVA_Slice_H264_Short; /* 12 bytes (2 bytes tail padding) */ +/// } DXVA_Slice_H264_Short; /* 10 bytes — PACKED, no tail padding */ /// ``` /// +/// **Ten bytes, not twelve** — `#[repr(C, packed)]`, and the single most important +/// number in this file after the picture-parameters offsets. See the module docs' +/// alignment section for the hardware measurement it comes from and for what +/// getting it wrong does to every record after the first. +/// /// Short format only. The long format (`DXVA_Slice_H264_Long`) additionally /// carries the derived reference lists and the prediction weight tables, which /// this backend does not build — a device offering only long-format configs is /// refused at decoder creation and the ladder answers with the FFmpeg rung, which /// does implement both. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[repr(C)] +#[repr(C, packed)] pub struct SliceH264Short { /// Byte offset of the slice's **start code** within the bitstream buffer. pub BSNALunitDataLocation: u32, @@ -787,10 +818,14 @@ impl QmatrixHevc { /// UINT BSNALunitDataLocation; /* 0 */ /// UINT SliceBytesInBuffer; /* 4 */ /// USHORT wBadSliceChopping; /* 8 */ -/// } DXVA_Slice_HEVC_Short; /* 12 bytes */ +/// } DXVA_Slice_HEVC_Short; /* 10 bytes — PACKED, no tail padding */ /// ``` +/// +/// Ten bytes for the same reason as [`SliceH264Short`], and measured independently: +/// the HEVC capture's slice-control `DataSize` is 10 on a vector with exactly one +/// slice segment per picture. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -#[repr(C)] +#[repr(C, packed)] pub struct SliceHevcShort { pub BSNALunitDataLocation: u32, pub SliceBytesInBuffer: u32, @@ -805,6 +840,18 @@ pub struct SliceHevcShort { // reproduced above. This is the whole defence against a silently mis-declared // buffer: nothing else in the pipeline can tell a wrong offset from a right one, // because the driver accepts either and only the picture differs. +// +// Three kinds of assertion, and the third is new because the first two missed a +// real defect (the slice records' 12-vs-10; see the module docs): +// +// 1. `size_of` per struct, against the C total. +// 2. `offset_of` per FIELD, which is what makes interior padding or a reordering +// impossible. +// 3. **size == last field's offset + last field's own size**, per struct — the +// assertion that catches TAIL padding, which is exactly what (1) and (2) cannot +// see: a struct whose declared total is itself wrong satisfies both. Every one of +// these buffers is a wire format with no padding anywhere, and this is where that +// is stated as a proof rather than a comment. const _: () = { assert!(size_of::() == 1); @@ -852,7 +899,8 @@ const _: () = { assert!(offset_of!(QmatrixH264, bScalingLists4x4) == 0); assert!(offset_of!(QmatrixH264, bScalingLists8x8) == 96); - assert!(size_of::() == 12); + assert!(size_of::() == 10); + assert!(align_of::() == 1); assert!(offset_of!(SliceH264Short, BSNALunitDataLocation) == 0); assert!(offset_of!(SliceH264Short, SliceBytesInBuffer) == 4); assert!(offset_of!(SliceH264Short, wBadSliceChopping) == 8); @@ -908,10 +956,25 @@ const _: () = { assert!(offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID2) == 992); assert!(offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID3) == 998); - assert!(size_of::() == 12); + assert!(size_of::() == 10); + assert!(align_of::() == 1); assert!(offset_of!(SliceHevcShort, BSNALunitDataLocation) == 0); assert!(offset_of!(SliceHevcShort, SliceBytesInBuffer) == 4); assert!(offset_of!(SliceHevcShort, wBadSliceChopping) == 8); + + // NO TAIL PADDING, per struct: the size is the last member's offset plus the + // last member's own size, nothing more. The right-hand sizes are the C + // declarations' (`UCHAR SliceGroupMap[810]`, `UINT StatusReportFeedbackNumber`, + // …), so each line is an independent statement of the total rather than a + // restatement of `size_of`. + assert!(size_of::() == offset_of!(PicParamsH264, SliceGroupMap) + 810); + assert!(size_of::() == offset_of!(QmatrixH264, bScalingLists8x8) + 2 * 64); + assert!(size_of::() == offset_of!(SliceH264Short, wBadSliceChopping) + 2); + assert!( + size_of::() == offset_of!(PicParamsHevc, StatusReportFeedbackNumber) + 4 + ); + assert!(size_of::() == offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID3) + 2); + assert!(size_of::() == offset_of!(SliceHevcShort, wBadSliceChopping) + 2); }; // --------------------------------------------------------------------------- @@ -1164,11 +1227,18 @@ mod tests { // reader who distrusts a `const _` finds the same claim executable. assert_eq!(size_of::(), 1040); assert_eq!(size_of::(), 224); - assert_eq!(size_of::(), 12); assert_eq!(size_of::(), 232); assert_eq!(size_of::(), 1000); - assert_eq!(size_of::(), 12); assert_eq!(size_of::(), 1); + // TEN, not twelve. Measured against libavcodec on hardware: the H.264 + // slice-control buffer is 20 bytes for a two-slice picture and the HEVC one + // 10 bytes for a one-slice-segment picture (module docs). A `#[repr(C)]` + // `{u32, u32, u16}` is 12, and every record after the first would then be + // displaced by two bytes per preceding record. + assert_eq!(size_of::(), 10); + assert_eq!(size_of::(), 10); + assert_eq!(align_of::(), 1); + assert_eq!(align_of::(), 1); } #[test] @@ -1207,10 +1277,16 @@ mod tests { }, ]; let bytes = slice_bytes(&records); - assert_eq!(bytes.len(), 24); + // TEN bytes per record, so the second record starts at byte 10 — this is the + // test that fails if the packing is ever relaxed back to natural alignment, + // and it fails on the SECOND record, which is exactly where the driver + // would have started misreading. + assert_eq!(bytes.len(), 20); assert_eq!(&bytes[0..4], &0u32.to_le_bytes()); assert_eq!(&bytes[4..8], &100u32.to_le_bytes()); - assert_eq!(&bytes[12..16], &100u32.to_le_bytes()); - assert_eq!(&bytes[16..20], &250u32.to_le_bytes()); + assert_eq!(&bytes[8..10], &0u16.to_le_bytes()); + assert_eq!(&bytes[10..14], &100u32.to_le_bytes()); + assert_eq!(&bytes[14..18], &250u32.to_le_bytes()); + assert_eq!(&bytes[18..20], &0u16.to_le_bytes()); } } diff --git a/crates/pf-dxvadec/src/lib.rs b/crates/pf-dxvadec/src/lib.rs index 20e7a4f1..9fafeaca 100644 --- a/crates/pf-dxvadec/src/lib.rs +++ b/crates/pf-dxvadec/src/lib.rs @@ -22,6 +22,10 @@ //! - [`pic`] / [`pic_h265`]: one [`pf_bitstream`] `AuPlan` into //! `DXVA_PicParams_*`, `DXVA_Qmatrix_*` and the slice-control records, with the //! reference lists resolved through a DPB slot map. +//! - [`descriptors`]: which buffers one `SubmitDecoderBuffers` call carries and +//! the four `D3D11_VIDEO_DECODER_BUFFER_DESC` fields that are a decision — +//! where two of review 13's three structural defects lived, and the reason +//! they are now a CPU test rather than a Windows-only code path. //! //! # Why the slot map comes from pf-vkdecode //! @@ -46,6 +50,7 @@ #![deny(clippy::undocumented_unsafe_blocks)] pub mod config; +pub mod descriptors; pub mod dxva; pub mod pack; pub mod pic; @@ -95,6 +100,13 @@ pub use config::DXGI_FORMAT_P010; pub use config::H264_VLD_NOFGT; pub use config::HEVC_VLD_MAIN; pub use config::HEVC_VLD_MAIN10; +pub use descriptors::descriptors_h264; +pub use descriptors::descriptors_h265; +pub use descriptors::BufferDescriptor; +pub use descriptors::BUFFER_BITSTREAM; +pub use descriptors::BUFFER_INVERSE_QUANTIZATION_MATRIX; +pub use descriptors::BUFFER_PICTURE_PARAMETERS; +pub use descriptors::BUFFER_SLICE_CONTROL; pub use dxva::as_bytes; pub use dxva::slice_bytes; pub use dxva::PicParamsH264; diff --git a/crates/pf-dxvadec/src/pic.rs b/crates/pf-dxvadec/src/pic.rs index a2c641c2..6546c0cd 100644 --- a/crates/pf-dxvadec/src/pic.rs +++ b/crates/pf-dxvadec/src/pic.rs @@ -1084,11 +1084,21 @@ mod tests { ]; let control = slice_control(&records); assert_eq!(control.len(), 2); - assert_eq!(control[0].BSNALunitDataLocation, 0); - assert_eq!(control[0].SliceBytesInBuffer, 40); - assert_eq!(control[1].BSNALunitDataLocation, 40); - assert_eq!(control[1].SliceBytesInBuffer, 216); - assert!(control.iter().all(|c| c.wBadSliceChopping == 0)); + // Read by VALUE, in braces: `DXVA_Slice_H264_Short` is `#[repr(C, packed)]` + // (ten bytes, see `dxva.rs`'s alignment section), so a reference to one of + // its `u32` members would be unaligned and is a compile error — `assert_eq!` + // takes references to its operands. + assert_eq!({ control[0].BSNALunitDataLocation }, 0); + assert_eq!({ control[0].SliceBytesInBuffer }, 40); + assert_eq!({ control[1].BSNALunitDataLocation }, 40); + assert_eq!({ control[1].SliceBytesInBuffer }, 216); + assert!(control.iter().all(|c| { c.wBadSliceChopping } == 0)); + // …and the records reach the driver ten bytes apart, which is the fact the + // whole submission depends on: the second record's location is at byte 10, + // not 12. + let bytes = crate::dxva::slice_bytes(&control); + assert_eq!(bytes.len(), 20); + assert_eq!(&bytes[10..14], &40u32.to_le_bytes()); } #[test] diff --git a/crates/pf-dxvadec/src/pic_h265.rs b/crates/pf-dxvadec/src/pic_h265.rs index 16889be3..dbb32293 100644 --- a/crates/pf-dxvadec/src/pic_h265.rs +++ b/crates/pf-dxvadec/src/pic_h265.rs @@ -1298,14 +1298,31 @@ mod tests { #[test] fn slice_control_records_carry_the_packers_locations_verbatim() { - let records = [crate::pack::SliceRecord { - location: 0, - bytes: 128, - }]; + let records = [ + crate::pack::SliceRecord { + location: 0, + bytes: 128, + }, + crate::pack::SliceRecord { + location: 128, + bytes: 256, + }, + ]; let control = slice_control_h265(&records); - assert_eq!(control[0].BSNALunitDataLocation, 0); - assert_eq!(control[0].SliceBytesInBuffer, 128); - assert_eq!(control[0].wBadSliceChopping, 0); + // Read by VALUE, in braces: the record is `#[repr(C, packed)]` (ten bytes), + // so a reference to a `u32` member would be unaligned — and `assert_eq!` + // takes references. See `dxva.rs`'s alignment section. + assert_eq!({ control[0].BSNALunitDataLocation }, 0); + assert_eq!({ control[0].SliceBytesInBuffer }, 128); + assert_eq!({ control[0].wBadSliceChopping }, 0); + // A SECOND record, because the ten-vs-twelve byte defect is invisible on a + // single-record buffer — the vendored HEVC vector is one slice segment per + // picture, which is precisely the shape that hid it. + assert_eq!({ control[1].BSNALunitDataLocation }, 128); + let bytes = crate::dxva::slice_bytes(&control); + assert_eq!(bytes.len(), 20); + assert_eq!(&bytes[10..14], &128u32.to_le_bytes()); + assert_eq!(&bytes[14..18], &256u32.to_le_bytes()); } #[test] diff --git a/crates/pf-dxvadec/tests/libav_picparams_parity.rs b/crates/pf-dxvadec/tests/libav_picparams_parity.rs index 4aa382a5..f5b770e9 100644 --- a/crates/pf-dxvadec/tests/libav_picparams_parity.rs +++ b/crates/pf-dxvadec/tests/libav_picparams_parity.rs @@ -1,5 +1,11 @@ -//! The byte-diff harness: pf-dxvadec's `DXVA_PicParams_H264` against libavcodec's, for the -//! same access units of the same elementary stream. +//! The parity harness: pf-dxvadec's DXVA submission against libavcodec's, for the same access +//! units of the same elementary stream — picture parameters, quantization matrices AND the +//! buffer descriptors, for both codecs. +//! +//! (The file is still called `libav_picparams_parity` although it long since outgrew the picture +//! parameters: the name is what the milestone's plan, the capture recipe below and the gate +//! `cargo test -p pf-dxvadec --test libav_picparams_parity` all say, and a rename would cost +//! every one of those a stale reference to buy nothing.) //! //! # Why this exists //! @@ -9,106 +15,377 @@ //! reading is exactly the method that produced the defects review 13 found: a quantization //! matrix submitted unconditionally, a `NumMBsInBuffer` left at zero, a `RefFrameList` holding //! the wrong set. None of those is visible in a smoke test, and all three are visible in one -//! `memcmp` against the path every Windows player exercises. +//! comparison against the path every Windows player exercises. //! -//! This harness is the cheap version of that `memcmp`. It is `#[ignore]`d because it needs a -//! capture the repository cannot carry: libavcodec's own picture parameters, produced by a -//! patched FFmpeg on a Windows box with a D3D11VA-capable GPU. +//! Two halves, and the split matters: +//! +//! * **What needs no capture at all** — the descriptor set's internal consistency, that the +//! quantization-matrix buffer is submitted exactly when the stream has one, that +//! `NumMBsInBuffer` is `mb_width * mb_height` for H.264 and 0 for HEVC, that the slice +//! records tile the bitstream buffer exactly. Those are ORDINARY tests here, not `#[ignore]`d, +//! because they run on any host over every AU of the vendored vectors. Two of review 13's +//! three structural defects would have failed one of them. +//! * **What needs libavcodec's own bytes** — the picture parameters, the matrices' contents, +//! and the descriptor VALUES as libav computes them. Those tests are `#[ignore]`d because they +//! need a capture the repository cannot carry: a patched FFmpeg on a Windows box with a +//! D3D11VA-capable GPU. //! //! # Capturing the libavcodec side //! -//! On the Windows box (192.168.1.173 — see the box notes for the ssh identity), with an -//! FFmpeg source tree: +//! Verified against **FFmpeg n8.1**, which is the version the Windows CI runs; the names below +//! are that tree's (they changed — the fill functions are non-static and codec-prefixed now). +//! On the Windows box (192.168.1.173 — see the box notes for the ssh identity), with an FFmpeg +//! source tree: //! -//! 1. Patch `libavcodec/dxva2_h264.c`. At the very END of `fill_picture_parameters` — after -//! the `RefFrameList` loop and the `UsedForReferenceFlags` writes, so every field is -//! final — add: +//! **1. One AU counter, shared by every line.** In `libavcodec/dxva2.c`, at file scope above +//! `ff_dxva2_commit_buffer` (dxva2.c:802): //! -//! ```c -//! { -//! static unsigned au_index; -//! const uint8_t *raw = (const uint8_t *)pp; -//! char line[2 * sizeof(*pp) + 32]; -//! unsigned i; -//! for (i = 0; i < sizeof(*pp); i++) -//! snprintf(line + 2 * i, 3, "%02x", raw[i]); -//! av_log(avctx, AV_LOG_INFO, "PFPP h264 %u %s\n", au_index++, line); -//! } -//! ``` +//! ```c +//! static unsigned pf_au_index; +//! ``` //! -//! The identical block goes at the end of `dxva2_hevc.c`'s `fill_picture_parameters`, with -//! `h264` replaced by `hevc`, once this harness grows the HEVC half. +//! and bump it exactly once per picture, at the TOP of `ff_dxva2_common_end_frame` — that +//! function runs once per submitted picture, so `pf_au_index` is the same number for all of one +//! AU's lines: //! -//! 2. Build FFmpeg (`--enable-d3d11va` is on by default on Windows) and decode the SAME -//! elementary stream this test plans — the vendored vector, which is in the repository at -//! `crates/pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264`: +//! ```c +//! const unsigned pf_au = pf_au_index++; /* first line of the function body */ +//! ``` //! -//! ```text -//! ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 \ -//! -i test-25fps.h264 -f null - 2> capture.log -//! ``` +//! (The lines below that live in other functions read the file-scope `pf_au_index - 1`; the +//! block for each says which.) Two assumptions, both of which the harness's preflight catches if +//! they fail: the DXVA hwaccel decodes one picture at a time (`start_frame` → `decode_slice`* → +//! `end_frame`, no frame threading), and no picture is refused BETWEEN its +//! `fill_picture_parameters` and its `ff_dxva2_common_end_frame` — a codec-level `end_frame` that +//! returns early on `slice_count <= 0` would log a `PFPP` line with no matching descriptors, and +//! the preflight refuses a capture whose AU indices are not exactly `0..250`. //! -//! A software fallback produces no lines at all, which is the check that the hwaccel -//! actually engaged: 250 `PFPP h264` lines or the capture is void. +//! **2. The buffer descriptors.** `ff_dxva2_commit_buffer` (dxva2.c:802) is the choke point for +//! three of the four buffers — it writes `dsc11->BufferType/DataSize/NumMBsInBuffer` at +//! dxva2.c:836-840. Immediately AFTER that write: //! -//! 3. `grep '^PFPP h264 ' capture.log > libav-h264.picparams`, copy it back, and run: +//! ```c +//! av_log(NULL, AV_LOG_INFO, "PFBD %s %u %u %u %u %u\n", +//! avcodec_get_name(avctx->codec_id), pf_au_index - 1, +//! (unsigned)type, (unsigned)size, (unsigned)mb_count, 0u); +//! ``` //! -//! ```text -//! PF_LIBAV_PICPARAMS=libav-h264.picparams cargo test -p pf-dxvadec --test \ -//! libav_picparams_parity -- --ignored --nocapture -//! ``` +//! ⚠ **The BITSTREAM descriptor does NOT pass through that function** — the bitstream buffer is +//! packed in place, so each codec's `commit_bitstream_and_slice_buffer` fills its descriptor +//! itself (`dxva2_h264.c:412` D3D11 / `:425` DXVA2, `dxva2_hevc.c:338` / `:349`). Add the same +//! line after each of those two fills, with the codec spelled literally: //! -//! `PF_DXVA_DUMP=` writes THIS side in the same format without needing a capture, so the -//! two files can also be diffed by hand. +//! ```c +//! av_log(NULL, AV_LOG_INFO, "PFBD h264 %u 6 %u %u 0\n", +//! pf_au_index - 1, (unsigned)current, mb_count); /* dxva2_h264.c */ +//! av_log(NULL, AV_LOG_INFO, "PFBD hevc %u 6 %u 0 0\n", +//! pf_au_index - 1, (unsigned)current); /* dxva2_hevc.c */ +//! ``` +//! +//! A capture whose AUs carry three `PFBD` lines instead of four is this patch site missed, and +//! the harness says so by name rather than reporting a missing buffer as a defect. +//! +//! **3. The picture parameters.** At the very END of `ff_dxva2_h264_fill_picture_parameters` +//! (`dxva2_h264.c:51`) — after the `RefFrameList` loop and the `UsedForReferenceFlags` writes, +//! so every field is final: +//! +//! ```c +//! { +//! const uint8_t *raw = (const uint8_t *)pp; +//! char line[2 * sizeof(*pp) + 1]; +//! unsigned i; +//! for (i = 0; i < sizeof(*pp); i++) +//! snprintf(line + 2 * i, 3, "%02x", raw[i]); +//! av_log(NULL, AV_LOG_INFO, "PFPP h264 %u %s\n", pf_au_index, line); +//! } +//! ``` +//! +//! `pf_au_index` un-decremented here on purpose: `fill_picture_parameters` runs from +//! `start_frame`, BEFORE `ff_dxva2_common_end_frame` bumps the counter for the same picture. +//! The identical block goes at the end of `ff_dxva2_hevc_fill_picture_parameters` +//! (`dxva2_hevc.c:60`) with `h264` replaced by `hevc`. +//! +//! **4. The quantization matrices, including whether they are submitted at all.** In +//! `ff_dxva2_common_end_frame`, where its `qm`/`qm_size` arguments are in scope (that is where +//! the codec's decision arrives: `dxva2_h264.c:513-516` passes `&ctx_pic->qm` with `sizeof(qm)` +//! UNCONDITIONALLY, while `dxva2_hevc.c:417,423-426` passes `NULL`/0 unless +//! `pp.dwCodingParamToolFlags & 1`): +//! +//! ```c +//! if (qm_size > 0) { +//! const uint8_t *raw = qm; +//! char *line = av_malloc(2 * qm_size + 1); +//! unsigned i; +//! for (i = 0; i < qm_size; i++) +//! snprintf(line + 2 * i, 3, "%02x", raw[i]); +//! av_log(NULL, AV_LOG_INFO, "PFQM %s %u %s\n", +//! avcodec_get_name(avctx->codec_id), pf_au, line); +//! av_free(line); +//! } else { +//! av_log(NULL, AV_LOG_INFO, "PFQM %s %u absent\n", +//! avcodec_get_name(avctx->codec_id), pf_au); +//! } +//! ``` +//! +//! The `absent` spelling is required rather than an omitted line: an omitted line is +//! indistinguishable from a missed patch, and "was the buffer submitted" is the single fact +//! review 13's HEVC defect turned on. +//! +//! **5. The slice-control format.** One line per AU (or one for the whole run — the parser takes +//! either), from the same place, so an inverted short/long-format number cannot pass unseen: +//! +//! ```c +//! av_log(NULL, AV_LOG_INFO, "PFCFG %s %u %u\n", +//! avcodec_get_name(avctx->codec_id), pf_au, +//! (unsigned)DXVA_CONTEXT_CFG_BITSTREAM(avctx, ctx)); +//! ``` +//! +//! `ConfigBitstreamRaw`'s short-format value is **2 for H.264 and 1 for HEVC** — one number +//! with two spellings, and an inverted pair swaps which slice-control STRUCT the driver reads +//! 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. +//! +//! **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}`: +//! +//! ```text +//! ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 -i test-25fps.h264 -f null - 2> h264.log +//! ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 -i test-25fps.h265 -f null - 2> hevc.log +//! grep -oE 'PF(PP|QM|BD|CFG) .*' h264.log > libav-h264.capture +//! grep -oE 'PF(PP|QM|BD|CFG) .*' hevc.log > libav-hevc.capture +//! ``` +//! +//! `av_log(NULL, …)` rather than `av_log(avctx, …)` throughout, and `grep -o` rather than an +//! anchored match, for one reason: FFmpeg's logger prefixes a message logged against a context +//! with `[h264 @ 0x…] `, which no anchored grep would match. The parser finds its marker anywhere +//! in a line, so a capture made either way is readable — but the flat form is what the recipe +//! asks for, because a capture that greps cleanly is a capture whose format can be eyeballed. +//! +//! A software fallback produces no lines at all, which is the check that the hwaccel actually +//! engaged: 250 `PFPP` lines per stream or the capture is void. Then: +//! +//! ```text +//! PF_LIBAV_CAPTURE_H264=libav-h264.capture PF_LIBAV_CAPTURE_HEVC=libav-hevc.capture \ +//! cargo test -p pf-dxvadec --test libav_picparams_parity -- --ignored --nocapture +//! ``` +//! +//! `PF_DXVA_DUMP=` writes THIS side in the same format, both codecs, without needing a +//! capture — so the two files can also be diffed by hand. //! //! # Differences that are EXPECTED, and must not be read as defects //! -//! A raw `memcmp` will differ in three places by design. The harness reports offsets rather -//! than a verdict for exactly this reason — read the offsets against this list: +//! A raw `memcmp` of the picture parameters differs by design, which is why this harness does +//! not do one. Each expected divergence is handled STRUCTURALLY instead, so what is left over is +//! a finding: //! -//! * **Surface indices.** `CurrPic` and every `RefFrameList` entry carry a decode-surface -//! index. libavcodec's comes from its own frame pool's allocation order; ours comes from +//! * **Surface indices.** `CurrPic` and every reference entry carry a decode-surface index. +//! libavcodec's comes from its own frame pool's allocation order; ours from //! [`pf_dxvadec::SlotMap`]. The two are a BIJECTION over the same pictures, never equal -//! numbers. A real comparison must build the mapping from the first AU that names a picture -//! and then check it holds; a differing index alone proves nothing. -//! * **`RefFrameList` ORDER.** libavcodec emits `short_ref` then `long_ref`; this crate emits -//! the AU's own references first and the rest of the marked DPB after (see -//! `pic.rs`'s module docs for why). Both are correct — DXVA imposes no order — so the array -//! must be compared as a SET of (marking, `FrameNumList`, `FieldOrderCntList`, used-flags) -//! tuples, with `UsedForReferenceFlags` re-indexed to the compared order. -//! * **Padding.** `DXVA_PicParams_H264` has no interior padding by construction (dxva.rs -//! proves the offsets at compile time), but the `Reserved*` fields are only defined where -//! this crate writes them deliberately; libavcodec zeroes the struct once at the top of the -//! frame and writes a subset. Any difference in a reserved field is a REAL finding for this -//! crate, not an expected divergence — that is what `Reserved16Bits = 3` is about. +//! numbers. The harness therefore tracks the mapping per PICTURE — identified by the pair-key +//! DXVA itself resolves references by — and reports only a mapping that CHANGES while the +//! picture is still in the DPB, or two live pictures collapsing onto one surface. A differing +//! index alone proves nothing; an index that stops agreeing does. +//! * **Reference-array ORDER.** For H.264 libavcodec emits `short_ref` then `long_ref`; this +//! crate emits the AU's own references first and the rest of the marked DPB after (see +//! `pic.rs`'s module docs for why). For HEVC libavcodec walks its DPB array in slot order +//! while this crate leads with the three current RPS sets (`pic_h265.rs`'s docs). Both are +//! correct — DXVA imposes no order, a driver resolves an entry by its keys — so the arrays are +//! compared as SETS of `(marking, key, POC, use-flags)` tuples, with the per-entry bits of +//! `UsedForReferenceFlags`/`NonExistingFrameFlags` carried inside each tuple, which is what +//! "re-indexed to the compared order" amounts to. +//! * **HEVC's RPS index arrays** (`RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr`) hold INDICES +//! into `RefPicList`, so a different array order means different index VALUES for the same +//! pictures. They are compared by resolving each index through its own side's `RefPicList` and +//! comparing the PICTURES named, position by position — position order is 8.3.4's and does +//! matter. +//! * **The bitstream buffer's `DataSize`** may legitimately differ; see the descriptor section +//! below, which states exactly how a legitimate difference is told from a defect. (On the +//! capture described below it does not differ at all, on any of 500 AUs.) +//! * **libavcodec's POC BASE.** Measured, not predicted: every `CurrFieldOrderCnt` and +//! `FieldOrderCntList` value in the H.264 capture is the specification's plus exactly **65536** +//! — FFmpeg seeds `prev_poc_msb = 1 << 16` at each IDR. The progression is identical; only the +//! base differs, and it is uniform across the current picture and every reference entry, so +//! every difference a driver computes from these fields (temporal direct, implicit weighted +//! prediction, co-located selection) is unaffected. This crate keeps 8.2.1's values and the +//! harness compares POCs RELATIVE to a base it derives from the first AU and then REQUIRES of +//! every AU after it — so a genuinely wrong POC still reports. libavcodec's HEVC POCs carry no +//! such offset (measured: base 0 on all 250 AUs). See `PocBase`. +//! * **HEVC `loop_filter_across_tiles_enabled_flag`** (bit 10 of +//! `dwCodingSettingPicturePropertyFlags`): ours 1, libavcodec's 0, on all 250 AUs. 7.4.3.3.1 +//! infers 1 when the PPS codes no tiles, which is what the vendored parser reports; libav's +//! parser evidently leaves it 0. Inert either way — with `tiles_enabled_flag` clear there is no +//! tile boundary for a loop filter to cross — so it is DOCUMENTED rather than changed: matching +//! libav would mean overriding a spec inference on the strength of one measurement of another +//! decoder's parser default. The allowance is exactly bit 10, exactly ours-set-theirs-clear, and +//! only while both sides agree tiles are off; see `hevc_allowance`. //! -//! Everything else — every parameter-set field, every flag word, `frame_num`, -//! `CurrFieldOrderCnt`, `ContinuationFlag`, `StatusReportFeedbackNumber` (both count from 1 -//! per picture) — must match byte for byte, and a difference there is the finding this harness -//! exists to produce. +//! The last two are reported on every run as DOCUMENTED divergences with their AU counts, never +//! silently dropped, and each one's allowance is narrow enough that the next difference in the same +//! field is still a finding — which two non-ignored tests +//! (`libavcodecs_constant_poc_base_is_documented_and_anything_else_about_a_poc_is_a_finding`, +//! `the_hevc_tiles_flag_allowance_is_exactly_bit_ten_with_tiles_disabled_and_nothing_else`) prove +//! by synthesising the differences an allowance must NOT absorb. //! -//! # What is not covered yet +//! Everything else — every parameter-set field, every flag word, `frame_num`, `ContinuationFlag`, +//! `StatusReportFeedbackNumber` (both count from 1 per picture), and every reserved field — must +//! match byte for byte, and a difference there is the finding this harness exists to produce. //! -//! The HEVC half (`DXVA_PicParams_HEVC` + `DXVA_Qmatrix_HEVC`), and the buffer DESCRIPTORS — -//! `NumMBsInBuffer`, `DataSize`, and whether the quantization-matrix buffer is submitted at -//! all. The descriptors are where two of review 13's three structural defects lived, and they -//! are not in `pp`: capturing them needs the same treatment applied to -//! `commit_bitstream_and_slice_buffer` and `ff_dxva2_commit_buffer`. +//! # What the first real run said +//! +//! Run on 2026-08-06 against a patched FFmpeg n8.1 capture from the RTX 4090 box, 250 AUs per +//! codec, both vendored vectors. **Four comparisons, zero undocumented divergences**: H.264 +//! picture parameters, HEVC picture parameters, the buffer descriptors of both codecs, and the +//! quantization matrices of both. The two divergences above were the entire delta. +//! +//! The measured ground truth, so the next reader needs no capture to know what libavcodec emits: +//! +//! | | H.264 | HEVC | +//! |---|---|---| +//! | picture parameters | 1040 bytes | 232 bytes | +//! | `ConfigBitstreamRaw` | 2 | 1 | +//! | IQ matrix | submitted on all 250 (224 bytes) | `absent` on all 250 | +//! | descriptors per AU | 4 (types 0, 4, 6, 5) | 3 (types 0, 6, 5) | +//! | `SLICE_CONTROL.DataSize` | 20 (2 slices × 10) | 10 (1 slice × 10) | +//! | `BITSTREAM.DataSize` | 256..6272, all ≡ 0 (mod 128) | 128..8320, all ≡ 0 (mod 128) | +//! | `NumMBsInBuffer` | 300 on BITSTREAM and SLICE_CONTROL, 0 on the other two | 0 on all | +//! | `DataOffset` | 0 on all | 0 on all | +//! +//! Three things that settles beyond this harness: the short slice record is **ten** bytes (20/2 and +//! 10/1, two codecs and two slice counts agreeing); every `BITSTREAM.DataSize` matches this crate's +//! packer exactly, so the start-code/rebase/padding rules were right; and the HEVC vector exercises +//! case 1 of the quantization matrix's three cases (`scaling_list_enabled_flag` clear) — cases 2 +//! and 3 remain CPU-only, which is stated rather than papered over. +//! +//! ## The two libavcodec workarounds, and why a capture can be VOID +//! +//! `Reserved16Bits = 3` is the notable field: libavcodec writes 3 for every standard profile and +//! 0 only under one of two workarounds, both of which also change other bytes. +//! +//! * `FF_DXVA2_WORKAROUND_INTEL_CLEARVIDEO` is set iff the negotiated decoder GUID is the legacy +//! `ff_DXVADDI_Intel_ModeH264_E` (dxva2.c:302-303), and it changes two H.264 things +//! (dxva2_h264.c:128 and :257). **Our side cannot select that GUID**: config.rs's table holds +//! three standard GUIDs — [`pf_dxvadec::H264_VLD_NOFGT`] is `DXVA2_ModeH264_E` — and +//! `video_d3d11_native.rs` asks the device for nothing else. The exposure is entirely on the +//! CAPTURE side: on an old Intel part, the FFmpeg producing the capture may negotiate +//! ClearVideo, and then its bytes are not ours to compare against. ⚠ Worth re-reading at the +//! Intel bring-up: modern parts negotiate the standard GUID, so this should not fire — but +//! "should not" is what a preflight is for. +//! * `FF_DXVA2_WORKAROUND_SCALING_LIST_ZIGZAG` (old ATI/AMD UVD) is never auto-set in the modern +//! hwaccel path — it is user-set through the legacy context only — so libav takes the +//! `ff_zigzag_scan`-indexed branch of `ff_dxva2_h264_fill_scaling_lists`, which emits the +//! matrices in CODED (zig-zag) order. That is the order this crate's matrices are already in +//! (the vendored parser stores each list as coded), which is what makes the H.264 +//! quantization-matrix comparison a straight byte compare. +//! +//! A capture whose `Reserved16Bits` is 0 was therefore made against a workaround path and is VOID +//! for comparison; the harness refuses it up front rather than reporting 250 findings. +//! +//! # The buffer descriptors +//! +//! [`pf_dxvadec::descriptors`] models them, and its module docs carry the value table and the +//! libavcodec citation for every field. What matters for a COMPARISON: +//! +//! * `CompressedBufferType` (D3D11's `BufferType`), the buffer SET and its ORDER cannot +//! legitimately differ. A type present on one side only is a finding — and for HEVC's +//! quantization matrix that finding IS review 13's defect. +//! * `DataOffset` is 0 on both sides, always. +//! * `NumMBsInBuffer` cannot legitimately differ: it is `mb_width * mb_height` on H.264's +//! bitstream and slice-control buffers, 0 everywhere else and on all of HEVC's. +//! * `DataSize` for the picture parameters, the matrices and the slice control cannot +//! legitimately differ either — they are `sizeof` a structure and `slices * 10` (the short +//! slice record is TEN bytes, packed; see `dxva.rs`'s alignment section for the measurement and +//! for what twelve would have cost). The slice control's size is therefore also a slice COUNT: +//! if it differs, the two sides disagree about how many slices the AU has, which voids that +//! AU's bitstream comparison and is reported as its own finding. +//! * `DataSize` for the BITSTREAM buffer is the one field with a legitimate divergence class. +//! Both sides pack slice NALUs only, each behind a normalised three-byte start code, and pad +//! the total to 128 bytes — this crate because the DXVA specs say so and because non-VCL NALUs +//! inside the decode range hang AMD's VCN firmware (the same discipline pf-vkdecode's +//! recording layer follows), libavcodec in `commit_bitstream_and_slice_buffer` for its own +//! reasons. So the sizes normally match exactly. They may differ by a FEW bytes per slice when +//! the two NALU splitters delimit a slice differently — trailing `zero_byte`s ahead of the next +//! start code belong to neither NALU, and a splitter may keep or drop them. That difference is +//! legitimate, and it is recognisable: the slice COUNT agrees, and the difference is under four +//! bytes per slice before padding. Anything else — a differing slice count, a size that is not +//! a multiple of 128 (which means the driver's mapping was too small for the padding), a +//! difference of hundreds of bytes — is a defect, and the harness classifies the two cases +//! apart rather than lumping them into one "DataSize differs". +//! +//! # Provenance, and what a reviewer must re-check +//! +//! There is no FFmpeg source in this worktree, so nothing here can verify a claim about +//! libavcodec. Two tiers, and the difference matters: +//! +//! * **Read out of an FFmpeg n8.1 tree** by this work package's coordinator: the function names +//! and every `file:line`, the qmatrix predicate's codec-asymmetry, the `NumMBsInBuffer` +//! asymmetry, and both workaround conditions. +//! * **Read out of the same tree, then CONFIRMED by the capture**: that +//! `commit_bitstream_and_slice_buffer` writes a three-byte start code ahead of each slice, +//! rebases `BSNALunitDataLocation` into the buffer, counts the start code in +//! `SliceBytesInBuffer`, pads with `FFMIN(128 - ((current - dxva_data) & 127), end - current)` +//! and charges that padding to the LAST record (`slice->SliceBytesInBuffer += padding`). This was +//! flagged as an unverified assumption in the first revision of this file, because the +//! `BITSTREAM.DataSize` classification rests on it; the capture then matched this crate's packed +//! size on all 500 AUs of both codecs, which is that assumption's proof. +//! +//! Everything in either tier is a contract this harness parses and compares against, not a fact +//! it establishes. The capture is the authority; a disagreement between a capture and a claim +//! above is a claim to fix. use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::fmt::Write as _; use std::io::Cursor; +use std::mem::offset_of; +use std::mem::size_of; +use std::ops::Range; -use cros_codecs::codec::h264::parser::Nalu; -use cros_codecs::codec::h264::parser::NaluType; -use pf_dxvadec::{plan_to_dxva, AuPlan, H264Planner, SlotMap}; +use pf_dxvadec::descriptors::BUFFER_BITSTREAM; +use pf_dxvadec::descriptors::BUFFER_INVERSE_QUANTIZATION_MATRIX; +use pf_dxvadec::descriptors::BUFFER_PICTURE_PARAMETERS; +use pf_dxvadec::descriptors::BUFFER_SLICE_CONTROL; +use pf_dxvadec::dxva::PicParamsH264; +use pf_dxvadec::dxva::PicParamsHevc; +use pf_dxvadec::dxva::QmatrixH264; +use pf_dxvadec::dxva::QmatrixHevc; +use pf_dxvadec::dxva::SliceH264Short; +use pf_dxvadec::dxva::SliceHevcShort; +use pf_dxvadec::dxva::UNUSED_ENTRY; +use pf_dxvadec::AuPlan; +use pf_dxvadec::BufferDescriptor; +use pf_dxvadec::Codec; +use pf_dxvadec::H264Planner; +use pf_dxvadec::H265Planner; +use pf_dxvadec::SliceRecord; +use pf_dxvadec::SlotMap; -const TEST_25FPS: &[u8] = include_bytes!( +const TEST_25FPS_H264: &[u8] = include_bytes!( "../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264" ); +const TEST_25FPS_H265: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" +); -/// The same AU splitter every test in this program uses: a new AU starts at a non-slice NALU -/// following a slice, or at a slice whose `first_mb_in_slice` is 0 following a slice. +/// Both vendored vectors carry exactly this many access units — pf-bitstream's own golden, and +/// the number of `PFPP` lines a valid capture holds. +const VENDORED_AUS: usize = 250; + +/// A generous stand-in for the driver's bitstream mapping. Real mappings are a few MiB; the +/// vendored vectors are 320x240, so nothing here comes close to the tail-padding clamp (which +/// [`pf_dxvadec::pack`]'s own unit tests cover). +const MAPPING_BYTES: usize = 1 << 20; + +// --------------------------------------------------------------------------- +// Access-unit splitting +// --------------------------------------------------------------------------- + +/// The same AU splitter every H.264 test in this program uses: a new AU starts at a non-slice +/// NALU following a slice, or at a slice whose `first_mb_in_slice` is 0 following a slice. fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { + use cros_codecs::codec::h264::parser::Nalu; + use cros_codecs::codec::h264::parser::NaluType; + let mut aus = Vec::new(); let mut cursor = Cursor::new(stream); let mut au_start = 0usize; @@ -131,125 +408,2773 @@ fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { aus } -/// This crate's picture parameters for the vendored vector, one entry per planned AU, as the -/// bytes a `SubmitDecoderBuffers` call would carry. -fn our_picparams() -> Vec<(usize, Vec)> { +/// The H.265 splitter, which is NOT the H.264 one: the flag that starts a picture is +/// `first_slice_segment_in_pic_flag`, the first bit after the TWO-byte NAL header, and "slice" is +/// every NALU type below 32. Copied from the tested implementation in +/// `crates/pf-bitstream/src/h265.rs` (`fn split_into_aus`, test-private there) rather than +/// re-derived, because a splitter that disagrees with pf-bitstream's would make every AU index +/// in a capture point at a different picture. +fn split_into_aus_h265(stream: &[u8]) -> Vec<&[u8]> { + use cros_codecs::codec::h265::parser::Nalu; + + let mut aus = Vec::new(); + let mut cursor = Cursor::new(stream); + let mut au_start = 0usize; + let mut au_has_slice = false; + + while let Ok(nalu) = Nalu::next(&mut cursor) { + let header_start = cursor.position() as usize; + let start = header_start - nalu.offset; + let is_slice = (nalu.header.type_ as u32) < 32; + let first_slice_flag = + is_slice && stream.get(header_start + 2).is_some_and(|b| b & 0x80 != 0); + + if au_has_slice && (!is_slice || first_slice_flag) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus +} + +// --------------------------------------------------------------------------- +// This crate's side +// --------------------------------------------------------------------------- + +/// Everything one AU's `SubmitDecoderBuffers` call would carry, from this crate. +struct OurSubmission { + /// The picture-parameters buffer's bytes. + pic_params: Vec, + /// The quantization-matrix buffer's bytes, or `None` when the buffer is not submitted at + /// all — which for HEVC is the whole of review 13's defect. + qmatrix: Option>, + /// The descriptor set, in submission order. + descriptors: Vec, + /// The packer's slice records, for the internal-consistency checks. + records: 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. + mb_count: u32, +} + +/// Plan and convert the whole vendored H.264 vector, one entry per AU. +/// +/// Every AU must plan and convert: this vector is pf-bitstream's clean golden, so a skipped AU +/// is a regression rather than a stream fact. Swallowing an error here — the shape the scaffold +/// this replaced had — is how a harness reports a clean bill of health while comparing nothing. +fn our_h264_submissions() -> Vec { let mut planner = H264Planner::new(); let mut slots: Option = None; + let mut mapping = vec![0u8; MAPPING_BYTES]; let mut out = Vec::new(); - for (i, au) in split_into_aus(TEST_25FPS).into_iter().enumerate() { - let plan: AuPlan = match planner.plan_au(au) { - Ok(plan) => plan, - Err(_) => continue, - }; + for (i, au) in split_into_aus(TEST_25FPS_H264).into_iter().enumerate() { + let plan: AuPlan = planner + .plan_au(au) + .unwrap_or_else(|e| panic!("AU {i} of the vendored H.264 vector must plan: {e}")); 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, out.len() as u32 + 1).expect("conversion"); - out.push((i, pf_dxvadec::as_bytes(&dxva.pic_params).to_vec())); + // `StatusReportFeedbackNumber` counts planned pictures from 1, which is exactly what + // 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}")); + 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; + out.push(OurSubmission { + pic_params: pf_dxvadec::as_bytes(&dxva.pic_params).to_vec(), + qmatrix: Some(pf_dxvadec::as_bytes(&dxva.qmatrix).to_vec()), + descriptors: pf_dxvadec::descriptors_h264(&dxva, &packed), + records: packed.records, + unpadded, + mb_count: dxva.mb_count, + }); + } + assert_eq!(out.len(), VENDORED_AUS); + out +} + +/// Plan and convert the whole vendored HEVC vector, one entry per AU. Same no-skipping contract +/// as the H.264 side — `RaslSkipped` cannot arise on a vector that starts at an IDR. +fn our_hevc_submissions() -> Vec { + let mut planner = H265Planner::new(); + let mut slots: Option = None; + let mut mapping = vec![0u8; MAPPING_BYTES]; + let mut out = Vec::new(); + for (i, au) in split_into_aus_h265(TEST_25FPS_H265).into_iter().enumerate() { + let plan = planner + .plan_au(au) + .unwrap_or_else(|e| panic!("AU {i} of the vendored HEVC vector must plan: {e}")); + 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 = pf_dxvadec::plan_to_dxva_h265(&plan, map, out.len() as u32 + 1) + .unwrap_or_else(|e| panic!("AU {i} must convert: {e}")); + 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; + out.push(OurSubmission { + pic_params: pf_dxvadec::as_bytes(&dxva.pic_params).to_vec(), + qmatrix: dxva + .qmatrix + .as_ref() + .map(|qm| pf_dxvadec::as_bytes(qm).to_vec()), + descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed), + records: packed.records, + unpadded, + mb_count: 0, + }); + } + assert_eq!(out.len(), VENDORED_AUS); + out +} + +// --------------------------------------------------------------------------- +// Offset → field name +// --------------------------------------------------------------------------- + +/// A field table for a hand-declared DXVA struct: `(name, offset)` per field, in declaration +/// 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. +macro_rules! field_table { + ($ty:ty, $($field:ident),+ $(,)?) => { + &[$((stringify!($field), offset_of!($ty, $field))),+] + }; +} + +/// Every field of `DXVA_PicParams_H264`. Lengths are DERIVED from the next field's offset rather +/// than written down: the struct has no interior padding (dxva.rs proves every offset at compile +/// time), so consecutive offsets tile it exactly — and a hand-typed length is one more thing that +/// can be wrong in a file whose whole job is to catch wrong numbers. +const H264_FIELDS: &[(&str, usize)] = field_table!( + PicParamsH264, + wFrameWidthInMbsMinus1, + wFrameHeightInMbsMinus1, + CurrPic, + num_ref_frames, + wBitFields, + bit_depth_luma_minus8, + bit_depth_chroma_minus8, + Reserved16Bits, + StatusReportFeedbackNumber, + RefFrameList, + CurrFieldOrderCnt, + FieldOrderCntList, + pic_init_qs_minus26, + chroma_qp_index_offset, + second_chroma_qp_index_offset, + ContinuationFlag, + pic_init_qp_minus26, + num_ref_idx_l0_active_minus1, + num_ref_idx_l1_active_minus1, + Reserved8BitsA, + FrameNumList, + UsedForReferenceFlags, + NonExistingFrameFlags, + frame_num, + log2_max_frame_num_minus4, + pic_order_cnt_type, + log2_max_pic_order_cnt_lsb_minus4, + delta_pic_order_always_zero_flag, + direct_8x8_inference_flag, + entropy_coding_mode_flag, + pic_order_present_flag, + num_slice_groups_minus1, + slice_group_map_type, + deblocking_filter_control_present_flag, + redundant_pic_cnt_present_flag, + Reserved8BitsB, + slice_group_change_rate_minus1, + SliceGroupMap, +); + +/// Every field of `DXVA_PicParams_HEVC`, same construction. +const HEVC_FIELDS: &[(&str, usize)] = field_table!( + PicParamsHevc, + PicWidthInMinCbsY, + PicHeightInMinCbsY, + wFormatAndSequenceInfoFlags, + CurrPic, + sps_max_dec_pic_buffering_minus1, + log2_min_luma_coding_block_size_minus3, + log2_diff_max_min_luma_coding_block_size, + log2_min_transform_block_size_minus2, + log2_diff_max_min_transform_block_size, + max_transform_hierarchy_depth_inter, + max_transform_hierarchy_depth_intra, + num_short_term_ref_pic_sets, + num_long_term_ref_pics_sps, + num_ref_idx_l0_default_active_minus1, + num_ref_idx_l1_default_active_minus1, + init_qp_minus26, + ucNumDeltaPocsOfRefRpsIdx, + wNumBitsForShortTermRPSInSlice, + ReservedBits2, + dwCodingParamToolFlags, + dwCodingSettingPicturePropertyFlags, + pps_cb_qp_offset, + pps_cr_qp_offset, + num_tile_columns_minus1, + num_tile_rows_minus1, + column_width_minus1, + row_height_minus1, + diff_cu_qp_delta_depth, + pps_beta_offset_div2, + pps_tc_offset_div2, + log2_parallel_merge_level_minus2, + CurrPicOrderCntVal, + RefPicList, + ReservedBits5, + PicOrderCntValList, + RefPicSetStCurrBefore, + RefPicSetStCurrAfter, + RefPicSetLtCurr, + ReservedBits6, + ReservedBits7, + StatusReportFeedbackNumber, +); + +/// `DXVA_Qmatrix_H264`'s two arrays. +const H264_QMATRIX_FIELDS: &[(&str, usize)] = + field_table!(QmatrixH264, bScalingLists4x4, bScalingLists8x8); + +/// The two short slice-control records, which are the structs the twelve-vs-ten defect was in. +const H264_SLICE_FIELDS: &[(&str, usize)] = field_table!( + SliceH264Short, + BSNALunitDataLocation, + SliceBytesInBuffer, + wBadSliceChopping, +); +const HEVC_SLICE_FIELDS: &[(&str, usize)] = field_table!( + SliceHevcShort, + BSNALunitDataLocation, + SliceBytesInBuffer, + wBadSliceChopping, +); + +/// `DXVA_Qmatrix_HEVC`'s six. +const HEVC_QMATRIX_FIELDS: &[(&str, usize)] = field_table!( + QmatrixHevc, + ucScalingLists0, + ucScalingLists1, + ucScalingLists2, + ucScalingLists3, + ucScalingListDCCoefSizeID2, + ucScalingListDCCoefSizeID3, +); + +/// Turn a field table into `(name, byte range)`, the last field running to `total`. +fn field_ranges( + fields: &[(&'static str, usize)], + total: usize, +) -> Vec<(&'static str, Range)> { + fields + .iter() + .enumerate() + .map(|(i, &(name, offset))| { + let end = fields.get(i + 1).map_or(total, |&(_, next)| next); + (name, offset..end) + }) + .collect() +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes([ + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ]) +} + +fn i32_at(bytes: &[u8], offset: usize) -> i32 { + u32_at(bytes, offset) as i32 +} + +// --------------------------------------------------------------------------- +// Findings +// --------------------------------------------------------------------------- + +/// One kind of divergence, and how often it happened. +struct Finding { + count: usize, + first_au: usize, + detail: String, +} + +/// Divergences, grouped by the FIELD they belong to rather than by byte offset. A byte offset is +/// nearly useless on a hand-declared struct; the crate proves its offsets at compile time, so +/// this maps them back to names and reports those. +/// +/// Two channels, and the split is the whole reason this type exists rather than a `Vec`: +/// [`Findings::note`] records a FINDING (the run fails), [`Findings::document`] records a +/// divergence this program has already decided is not a defect. Documented ones are printed on +/// every run with their reason and their AU count — never dropped, because a divergence nobody +/// prints is a divergence nobody re-reads, and each of them is only allowed within a stated +/// allowance that the comparison itself enforces. +#[derive(Default)] +struct Findings { + by_field: BTreeMap, + documented: BTreeMap, +} + +impl Findings { + fn note(&mut self, field: impl Into, au: usize, detail: impl Into) { + let entry = self + .by_field + .entry(field.into()) + .or_insert_with(|| Finding { + count: 0, + first_au: au, + detail: detail.into(), + }); + entry.count += 1; + } + + /// A divergence the module docs list, with the reason it is not a defect. + fn document(&mut self, field: impl Into, au: usize, reason: impl Into) { + let entry = self + .documented + .entry(field.into()) + .or_insert_with(|| Finding { + count: 0, + first_au: au, + detail: reason.into(), + }); + entry.count += 1; + } + + fn is_empty(&self) -> bool { + self.by_field.is_empty() + } + + fn fields(&self) -> Vec<&str> { + self.by_field.keys().map(String::as_str).collect() + } + + fn documented_fields(&self) -> Vec<&str> { + self.documented.keys().map(String::as_str).collect() + } + + /// Print the verdict and fail if there is one. Never silently passes: a run that classified + /// nothing prints the AU count it did compare, so "no findings" cannot be confused with + /// "nothing was compared". + fn verdict(&self, what: &str, aus: usize) { + for (field, documented) in &self.documented { + println!( + "{what}: {field} diverges on {} of {aus} AUs (first at AU {}) — DOCUMENTED, not a \ + defect: {}", + documented.count, documented.first_au, documented.detail + ); + } + if self.is_empty() { + println!("{what}: {aus} AUs compared, no undocumented divergence"); + return; + } + println!( + "{what}: {aus} AUs compared, {} fields diverge:", + self.by_field.len() + ); + for (field, finding) in &self.by_field { + println!( + " {field}: {} AUs, first at AU {} — {}", + finding.count, finding.first_au, finding.detail + ); + } + panic!( + "{what}: {} fields diverge ({}) — read each against the module docs' list of \ + expected divergences before treating it as a defect", + self.by_field.len(), + self.fields().join(", ") + ); + } +} + +// --------------------------------------------------------------------------- +// Reference entries as pictures +// --------------------------------------------------------------------------- + +/// A picture's identity as the reference arrays express it, and the key the surface mapping is +/// tracked by: `(long-term, FrameNum or LongTermFrameIdx, TopFieldOrderCnt, +/// BottomFieldOrderCnt)`. HEVC leaves the second and fourth members at 0 — it identifies a +/// reference by POC alone. +/// +/// It is DXVA's own key, deliberately, so both sides express it the same way. The one consequence +/// worth naming: a picture that is re-marked long-term changes key (`FrameNum` becomes +/// `LongTermFrameIdx`), so the surface mapping loses the link to its earlier self rather than +/// reporting a change — a missed check, never a false finding. Both sides re-key identically, so +/// the SET comparison is unaffected. +type PictureKey = (bool, u16, i32, i32); + +/// A reference entry with its surface index REMOVED: the identity DXVA resolves a reference by, +/// plus the per-entry flag bits that belong to it. Comparing the array as a multiset of these is +/// what makes the two sides' different orders irrelevant while keeping every fact — the flag +/// bits travel with their entry, which is "re-indexed to the compared order" in practice. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)] +struct RefEntry { + long_term: bool, + /// `FrameNumList[i]` (H.264): `frame_num`, or `LongTermFrameIdx` for a long-term entry. + /// Always 0 for HEVC, which has no such array. + frame_num_or_lt_idx: u16, + /// `FieldOrderCntList[i]` (H.264) or `PicOrderCntValList[i]` (HEVC, in `top`). + top: i32, + bottom: i32, + used_top: bool, + used_bottom: bool, + non_existing: bool, +} + +impl RefEntry { + /// The identity half alone — what follows a picture across AUs, and therefore what the + /// surface mapping is keyed by. + fn key(self) -> PictureKey { + ( + self.long_term, + self.frame_num_or_lt_idx, + self.top, + self.bottom, + ) + } +} + +/// FFmpeg's H.264 POC base, and the proof that it is a CONSTANT. +/// +/// libavcodec's H.264 decoder seeds `prev_poc_msb = 1 << 16` at every IDR, so every +/// `TopFieldOrderCnt`/`BottomFieldOrderCnt` it hands DXVA is the specification's plus 65536 — +/// measured, on the RTX 4090 capture: AUs 0..7 carry 65536, 65540, 65538, 65544, … where 8.2.1 +/// (and this crate) derive 0, 4, 2, 8, …. The PROGRESSION is identical; only the base differs. +/// +/// This crate keeps the spec's values, deliberately: the offset is an artefact of another +/// decoder's POC bookkeeping, not a DXVA requirement, and importing it would mean writing a magic +/// 65536 into a derivation that pf-bitstream shares with the Vulkan rung. It is harmless on the +/// wire because every use a driver makes of these fields is a DIFFERENCE — temporal direct +/// scaling, implicit weighted prediction, co-located picture selection — and the offset is uniform +/// across `CurrPic` and every `RefFrameList` entry of a stream, so it cancels. (References are +/// matched by `FrameNumList`, not by POC.) That it is uniform is exactly what this type checks. +/// +/// So POCs are compared RELATIVE: the offset is derived from the first AU and then required to +/// hold for every POC of every later AU. A wrong POC on either side changes the offset and is +/// reported. Only 0 and 65536 are accepted as the base itself — any other constant is a finding, +/// because then it is not the quirk documented here. +#[derive(Default)] +struct PocBase { + offset: Option, +} + +impl PocBase { + /// The offset in force, or 0 before the first AU has established one. + fn offset(&self) -> i64 { + self.offset.unwrap_or(0) + } + + /// Check one POC pair, establishing the base on the first call. + fn check(&mut self, au: usize, field: &str, ours: i32, theirs: i32, findings: &mut Findings) { + let delta = i64::from(theirs) - i64::from(ours); + match self.offset { + None => { + if delta != 0 && delta != 65536 { + findings.note( + format!("{field}[POC base]"), + au, + format!( + "libav's first POC is ours {ours} + {delta}, which is neither 0 nor \ + FFmpeg's documented 65536 `prev_poc_msb` seed — an unexplained POC \ + base is a finding, not a quirk to absorb" + ), + ); + } + if delta == 65536 { + findings.document( + "FieldOrderCnt[POC base]", + au, + "libavcodec seeds `prev_poc_msb = 1 << 16` at every IDR, so its POCs are \ + the specification's plus 65536; this crate keeps 8.2.1's values and the \ + harness compares POCs RELATIVE to that constant, which it requires to \ + hold on every AU", + ); + } + self.offset = Some(delta); + } + Some(offset) if delta != offset => findings.note( + format!("{field}[POC]"), + au, + format!( + "ours {ours}, libav {theirs}: a difference of {delta} where every earlier POC \ + of this stream differed by {offset} — the POC base is not constant, so this \ + is a real POC divergence rather than libav's base offset" + ), + ), + Some(_) => {} + } + } +} + +/// Subtract libav's POC base from a decoded reference array, so the set comparison compares +/// pictures rather than POC bases (see [`PocBase`]). +/// +/// `bottom_too` is true for H.264, whose entries carry a real `FieldOrderCntList[i][2]` PAIR, and +/// false for HEVC, whose `bottom` member is a placeholder this harness leaves at 0 on both sides — +/// shifting it would invent a difference rather than absorb one. +fn shift_poc(entries: &mut [(u8, RefEntry)], offset: i64, bottom_too: bool) { + for (_, entry) in entries.iter_mut() { + entry.top = (i64::from(entry.top) - offset) as i32; + if bottom_too { + entry.bottom = (i64::from(entry.bottom) - offset) as i32; + } + } +} + +/// A per-field allowance: `Some(reason)` when THIS difference in THIS field is a divergence the +/// module docs have already settled, rather than a finding. +/// +/// Deliberately per-DIFFERENCE and not per-field: a field with an allowance still reports anything +/// outside it. An allowlist keyed by field name alone would be the "vacuous green" this program +/// has been bitten by before — it would hide the next real difference in the same word. +type Allowance = fn(&str, &[u8], &[u8]) -> Option<&'static str>; + +/// H.264 has none: every difference in a scalar field is a finding. +fn no_allowance(_: &str, _: &[u8], _: &[u8]) -> Option<&'static str> { + None +} + +/// HEVC's one documented scalar divergence: bit 10 of `dwCodingSettingPicturePropertyFlags`, +/// `loop_filter_across_tiles_enabled_flag`, which this crate sets and libavcodec does not. +/// +/// 7.4.3.3.1 infers the flag to be 1 when the PPS does not code it, and the PPS only codes it +/// under `tiles_enabled_flag` — so with tiles disabled, 1 is what the specification says and what +/// the vendored parser reports. libavcodec's capture carries 0 on all 250 AUs of the vendored +/// vector. Neither can change a decoded picture: with `tiles_enabled_flag` clear there are no tile +/// boundaries for a loop filter to cross, which is why this is documented rather than fixed — +/// matching libavcodec here would mean overriding a spec inference on the strength of one +/// measurement of another decoder's parser default, and that default is not readable from this +/// worktree. +/// +/// The allowance is tight: ONLY bit 10, only ours-set-theirs-clear, and only while both sides +/// agree tiles are disabled. Any other difference in the same word — including bit 10 with tiles +/// ENABLED, where the flag stops being inert — is a finding. +fn hevc_allowance(field: &str, ours: &[u8], theirs: &[u8]) -> Option<&'static str> { + /// `tiles_enabled_flag`. + const TILES: u32 = 1 << 7; + /// `loop_filter_across_tiles_enabled_flag`. + const ACROSS_TILES: u32 = 1 << 10; + + if field != "dwCodingSettingPicturePropertyFlags" { + return None; + } + let (Ok(ours), Ok(theirs)) = ( + <[u8; 4]>::try_from(ours).map(u32::from_le_bytes), + <[u8; 4]>::try_from(theirs).map(u32::from_le_bytes), + ) else { + return None; + }; + let only_bit_10 = ours ^ theirs == ACROSS_TILES; + let ours_sets_it = ours & ACROSS_TILES != 0; + let tiles_off = (ours | theirs) & TILES == 0; + (only_bit_10 && ours_sets_it && tiles_off).then_some( + "loop_filter_across_tiles_enabled_flag (bit 10): 7.4.3.3.1 infers 1 when the PPS codes no \ + tiles and the vendored parser reports that; libavcodec emits 0. Inert either way — with \ + tiles_enabled_flag clear there is no tile boundary for a loop filter to cross", + ) +} + +/// One side's H.264 reference array, decoded: the in-use entries with their surface indices. +fn h264_ref_entries(pp: &[u8]) -> Vec<(u8, RefEntry)> { + let list = offset_of!(PicParamsH264, RefFrameList); + let poc = offset_of!(PicParamsH264, FieldOrderCntList); + let nums = offset_of!(PicParamsH264, FrameNumList); + let used = u32_at(pp, offset_of!(PicParamsH264, UsedForReferenceFlags)); + let missing = u16_at(pp, offset_of!(PicParamsH264, NonExistingFrameFlags)); + (0..16) + .filter(|i| pp[list + i] != UNUSED_ENTRY) + .map(|i| { + ( + pp[list + i] & 0x7F, + RefEntry { + long_term: pp[list + i] & 0x80 != 0, + frame_num_or_lt_idx: u16_at(pp, nums + 2 * i), + top: i32_at(pp, poc + 8 * i), + bottom: i32_at(pp, poc + 8 * i + 4), + used_top: used >> (2 * i) & 1 != 0, + used_bottom: used >> (2 * i + 1) & 1 != 0, + non_existing: missing >> i & 1 != 0, + }, + ) + }) + .collect() +} + +/// One side's HEVC reference array, decoded. HEVC's array carries no `FrameNum` and no use +/// flags — residency IS the statement — so those members stay at their neutral values. +fn hevc_ref_entries(pp: &[u8]) -> Vec<(u8, RefEntry)> { + let list = offset_of!(PicParamsHevc, RefPicList); + let poc = offset_of!(PicParamsHevc, PicOrderCntValList); + (0..15) + .filter(|i| pp[list + i] != UNUSED_ENTRY) + .map(|i| { + ( + pp[list + i] & 0x7F, + RefEntry { + long_term: pp[list + i] & 0x80 != 0, + frame_num_or_lt_idx: 0, + top: i32_at(pp, poc + 4 * i), + bottom: 0, + used_top: true, + used_bottom: true, + non_existing: false, + }, + ) + }) + .collect() +} + +/// 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 +/// surface once its picture leaves the DPB, and they need not reuse it at the same moment. What +/// must hold is that while a picture is live, its two surface numbers keep agreeing — so the +/// mapping is keyed by the picture and dropped when a side reassigns the index. +#[derive(Default)] +struct SurfaceMapping { + live: BTreeMap, +} + +impl SurfaceMapping { + /// Record one AU's pairs, reporting a mapping that changed under a live picture and two + /// pictures collapsing onto one surface. + fn observe( + &mut self, + au: usize, + field: &str, + pairs: &[(PictureKey, (u8, u8))], + findings: &mut Findings, + ) { + let mut ours_seen: BTreeMap = BTreeMap::new(); + let mut theirs_seen: BTreeMap = BTreeMap::new(); + for &(key, (ours, theirs)) in pairs { + if let Some(&(known_ours, known_theirs)) = self.live.get(&key) { + if (known_ours, known_theirs) != (ours, theirs) { + findings.note( + format!("{field}[surface mapping]"), + au, + format!( + "picture {key:?} was surface {known_ours} (ours) = {known_theirs} \ + (libav) and is now {ours} = {theirs}: the mapping is not a \ + bijection over this picture's lifetime" + ), + ); + } + } + if let Some(other) = ours_seen.insert(ours, key) { + if other != key { + findings.note( + format!("{field}[surface aliasing]"), + au, + format!("our surface {ours} carries both {other:?} and {key:?}"), + ); + } + } + if let Some(other) = theirs_seen.insert(theirs, key) { + if other != key { + findings.note( + format!("{field}[surface aliasing]"), + au, + format!("libav's surface {theirs} carries both {other:?} and {key:?}"), + ); + } + } + self.live.insert(key, (ours, theirs)); + } + // A surface either side has just reassigned no longer says anything about the picture + // that used to hold it, so the stale entries go — that is the difference between + // "the mapping broke" and "the pool moved on". + self.live.retain(|key, &mut (ours, theirs)| { + let ours_now = ours_seen.get(&ours); + let theirs_now = theirs_seen.get(&theirs); + ours_now.is_none_or(|k| k == key) && theirs_now.is_none_or(|k| k == key) + }); + } +} + +// --------------------------------------------------------------------------- +// The capture +// --------------------------------------------------------------------------- + +/// One captured buffer descriptor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CapturedDescriptor { + buffer_type: u32, + data_size: u32, + num_mbs_in_buffer: u32, + data_offset: u32, +} + +/// A parsed capture, for ONE codec. +#[derive(Default)] +struct Capture { + pic_params: BTreeMap>, + /// `Some(bytes)` for a submitted matrix, `None` for the explicit `absent` spelling. An AU + /// missing from this map was never reported either way, which is itself a finding. + qmatrix: BTreeMap>>, + descriptors: BTreeMap>, + config_bitstream_raw: BTreeMap, + /// Lines that carry one of this harness's prefixes and could not be read. Never dropped + /// silently: a capture whose format drifted must fail loudly, not compare less. + unreadable: Vec, +} + +/// `hex` → bytes, or `None` when it is not an even-length hex string. +fn from_hex(hex: &str) -> Option> { + if hex.len() % 2 != 0 || hex.is_empty() { + return None; + } + (0..hex.len() / 2) + .map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).ok()) + .collect() +} + +fn to_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(2 * bytes.len()); + for b in bytes { + let _ = write!(out, "{b:02x}"); } out } -/// `PFPP h264 ` lines → index → bytes. -fn parse_capture(text: &str) -> BTreeMap> { - let mut out = BTreeMap::new(); - for line in text.lines() { - let rest = match line.trim().strip_prefix("PFPP h264 ") { - Some(rest) => rest, - None => continue, - }; - let mut parts = rest.split_whitespace(); - let (Some(index), Some(hex)) = (parts.next(), parts.next()) else { +/// Parse the lines of one codec out of a capture. `codec` is the token FFmpeg's +/// `avcodec_get_name` produces: `h264` or `hevc`. +fn parse_capture(text: &str, codec: &str) -> Capture { + /// The markers, with their trailing space so a bare word cannot match one. + const MARKERS: [&str; 4] = ["PFPP ", "PFQM ", "PFBD ", "PFCFG "]; + + let mut out = Capture::default(); + for raw in text.lines() { + // The marker is found ANYWHERE in the line, not required at its start: FFmpeg's logger + // prefixes a message logged against a codec context with `[h264 @ 0x…] `, and a capture + // made that way must still be readable (the recipe asks for `av_log(NULL, …)` so it is + // not, but a capture is expensive and this costs nothing). + let Some(start) = MARKERS.iter().filter_map(|m| raw.find(m)).min() else { continue; }; - let index: usize = index.parse().expect("a decimal AU index"); - assert!(hex.len() % 2 == 0, "AU {index}: odd hex length"); - let bytes = (0..hex.len() / 2) - .map(|i| u8::from_str_radix(&hex[2 * i..2 * i + 2], 16).expect("hex")) - .collect(); - out.insert(index, bytes); + let line = raw[start..].trim(); + let Some((prefix, rest)) = line.split_once(' ') else { + continue; + }; + if !matches!(prefix, "PFPP" | "PFQM" | "PFBD" | "PFCFG") { + continue; + } + let fields: Vec<&str> = rest.split_whitespace().collect(); + // Every line is ` …`, so anything shorter is malformed. + let (Some(line_codec), Some(au)) = (fields.first(), fields.get(1)) else { + out.unreadable.push(line.to_string()); + continue; + }; + if *line_codec != codec { + continue; + } + let Ok(au) = au.parse::() else { + out.unreadable.push(line.to_string()); + continue; + }; + let ok = match (prefix, &fields[2..]) { + ("PFPP", [hex]) => match from_hex(hex) { + Some(bytes) => out.pic_params.insert(au, bytes).is_none(), + None => false, + }, + ("PFQM", ["absent"]) => out.qmatrix.insert(au, None).is_none(), + ("PFQM", [hex]) => match from_hex(hex) { + Some(bytes) => out.qmatrix.insert(au, Some(bytes)).is_none(), + None => false, + }, + ("PFBD", [kind, size, mbs, offset]) => { + match ( + kind.parse::(), + size.parse::(), + mbs.parse::(), + offset.parse::(), + ) { + (Ok(buffer_type), Ok(data_size), Ok(num_mbs_in_buffer), Ok(data_offset)) => { + out.descriptors + .entry(au) + .or_default() + .push(CapturedDescriptor { + buffer_type, + data_size, + num_mbs_in_buffer, + data_offset, + }); + true + } + _ => false, + } + } + ("PFCFG", [raw]) => match raw.parse::() { + Ok(raw) => { + out.config_bitstream_raw.insert(au, raw); + true + } + Err(_) => false, + }, + _ => false, + }; + if !ok { + out.unreadable.push(line.to_string()); + } } out } -/// Emit this crate's side in the capture's own format — usable without a capture at all, so -/// the two files can be diffed by any tool. +/// Read the capture named by `var`, or `None` when it is unset. +fn capture_from_env(var: &str, codec: &str) -> Option { + let path = std::env::var(var).ok()?; + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("{var}={path} could not be read: {e}")); + Some(parse_capture(&text, codec)) +} + +/// The checks every comparison needs before it compares anything: the capture is readable, it +/// covers the same AUs, and it was not made against a workaround path. +fn preflight(capture: &Capture, ours: usize, codec: &str, reserved16: Option) { + assert!( + capture.unreadable.is_empty(), + "the capture holds {} unreadable line(s) — the first is:\n {}\nthe recipe in this \ + file's module docs is the format", + capture.unreadable.len(), + capture.unreadable[0] + ); + assert!( + !capture.pic_params.is_empty(), + "the capture holds no `PFPP {codec}` lines: either the patch did not apply or the \ + hwaccel never engaged (a software fallback logs nothing)" + ); + assert_eq!( + capture.pic_params.len(), + ours, + "the capture covers {} AUs and this crate plans {ours} — the two sides must decode the \ + same elementary stream, split the same way", + capture.pic_params.len() + ); + let expected: BTreeSet = (0..ours).collect(); + let seen: BTreeSet = capture.pic_params.keys().copied().collect(); + assert_eq!( + seen, expected, + "the capture's AU indices are not 0..{ours}; pairing by index would compare different \ + pictures" + ); + if let Some(offset) = reserved16 { + let zeroed = capture + .pic_params + .values() + .filter(|pp| pp.len() > offset + 1 && u16_at(pp, offset) == 0) + .count(); + assert_eq!( + zeroed, 0, + "{zeroed} of {ours} captured pictures carry Reserved16Bits = 0, which libavcodec \ + writes only under FF_DXVA2_WORKAROUND_INTEL_CLEARVIDEO or \ + FF_DXVA2_WORKAROUND_SCALING_LIST_ZIGZAG — this capture is against a workaround \ + path and is VOID for comparison (module docs)" + ); + } + // A `PFCFG` line is optional, but a wrong one voids the slice-control comparison: it means + // the driver read the other slice-control struct entirely. + let want = pf_dxvadec::short_slice_config(match codec { + "h264" => Codec::H264, + _ => Codec::H265, + }); + for (au, &raw) in &capture.config_bitstream_raw { + assert_eq!( + raw, want, + "AU {au}: the capture's ConfigBitstreamRaw is {raw}, and short format for {codec} \ + is {want} — the capture used the other slice-control format, so its slice-control \ + sizes describe a different struct" + ); + } +} + +// --------------------------------------------------------------------------- +// The comparisons +// --------------------------------------------------------------------------- + +/// How two versions of one field differ, in a form a reader can act on: the whole value in hex +/// when it is small enough to read, and a count plus the first differing byte when it is an array +/// (a `SliceGroupMap` printed in full is 1620 characters of nothing). +fn byte_diff_detail(ours: &[u8], theirs: &[u8]) -> String { + if ours.len() <= 8 { + return format!("ours {}, libav {}", to_hex(ours), to_hex(theirs)); + } + let first = ours + .iter() + .zip(theirs) + .position(|(a, b)| a != b) + .unwrap_or(0); + let differing = ours.iter().zip(theirs).filter(|(a, b)| a != b).count(); + format!( + "{differing} of {} bytes differ, first at byte {first} of the field (ours {:#04x}, libav \ + {:#04x})", + ours.len(), + ours[first], + theirs[first] + ) +} + +/// Compare the scalar fields — everything that is neither a surface index nor part of a +/// reference array — byte for byte, reporting by field name. +fn compare_scalars( + au: usize, + ours: &[u8], + theirs: &[u8], + ranges: &[(&'static str, Range)], + structural: &[&str], + allowance: Allowance, + findings: &mut Findings, +) { + let mut classified = vec![false; ours.len()]; + for (name, range) in ranges { + for byte in range.clone() { + classified[byte] = true; + } + if structural.contains(name) { + continue; + } + if ours[range.clone()] != theirs[range.clone()] { + match allowance(name, &ours[range.clone()], &theirs[range.clone()]) { + Some(reason) => findings.document(*name, au, reason), + None => findings.note( + *name, + au, + byte_diff_detail(&ours[range.clone()], &theirs[range.clone()]), + ), + } + } + } + // The field table must tile the struct; if a byte ever falls outside it, report it as a raw + // offset rather than pass over it. That is the fallback the module docs promise, and the + // reason this harness can never silently ignore a difference it cannot name. + for (offset, covered) in classified.iter().enumerate() { + if !covered && ours[offset] != theirs[offset] { + findings.note( + format!(""), + au, + format!("ours {:#04x}, libav {:#04x}", ours[offset], theirs[offset]), + ); + } + } +} + +/// Compare one AU's reference array as a SET, and feed the surface mapping. +fn compare_ref_array( + au: usize, + field: &str, + ours: &[(u8, RefEntry)], + theirs: &[(u8, RefEntry)], + mapping: &mut SurfaceMapping, + findings: &mut Findings, +) { + let mut ours_sorted: Vec = ours.iter().map(|&(_, e)| e).collect(); + let mut theirs_sorted: Vec = theirs.iter().map(|&(_, e)| e).collect(); + ours_sorted.sort_unstable(); + theirs_sorted.sort_unstable(); + if ours_sorted != theirs_sorted { + let only_ours: Vec<&RefEntry> = ours_sorted + .iter() + .filter(|e| !theirs_sorted.contains(e)) + .collect(); + let only_theirs: Vec<&RefEntry> = theirs_sorted + .iter() + .filter(|e| !ours_sorted.contains(e)) + .collect(); + findings.note( + format!("{field}[set]"), + au, + format!( + "{} entries ours vs {} libav; only ours: {only_ours:?}; only libav: {only_theirs:?}", + ours.len(), + theirs.len() + ), + ); + } + + // The pairs the mapping is built from: a picture present on both sides, identified by its + // key. An ambiguous key (two entries claiming the same identity) is skipped and reported — + // it cannot happen off a conformant stream, and guessing which is which would invent a + // mapping. + let mut pairs = Vec::new(); + for &(our_slot, entry) in ours { + let key = entry.key(); + let ours_same = ours.iter().filter(|(_, e)| e.key() == key).count(); + let matches: Vec = theirs + .iter() + .filter(|(_, e)| e.key() == key) + .map(|&(slot, _)| slot) + .collect(); + if ours_same > 1 || matches.len() > 1 { + findings.note( + format!("{field}[ambiguous key]"), + au, + format!( + "{key:?} appears {ours_same} times ours and {} libav", + matches.len() + ), + ); + continue; + } + if let Some(&their_slot) = matches.first() { + pairs.push((key, (our_slot, their_slot))); + } + } + mapping.observe(au, field, &pairs, findings); +} + +/// The whole H.264 picture-parameter comparison. +fn compare_h264_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings { + let ranges = field_ranges(H264_FIELDS, size_of::()); + let structural = [ + "CurrPic", + // The POC fields are compared RELATIVE to libavcodec's base offset rather than byte for + // byte — see `PocBase` for the measurement and for why this crate keeps 8.2.1's values. + "CurrFieldOrderCnt", + "RefFrameList", + "FieldOrderCntList", + "FrameNumList", + "UsedForReferenceFlags", + "NonExistingFrameFlags", + ]; + let mut findings = Findings::default(); + let mut mapping = SurfaceMapping::default(); + let mut poc = PocBase::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 AU", + ); + continue; + }; + if theirs.len() != sub.pic_params.len() { + findings.note( + "", + au, + format!( + "the capture's picture parameters are {} bytes and ours are {} — the \ + hand-declared layout and the header disagree, which is a finding on its own", + theirs.len(), + sub.pic_params.len() + ), + ); + continue; + } + compare_scalars( + au, + &sub.pic_params, + theirs, + &ranges, + &structural, + no_allowance, + &mut findings, + ); + // The POC fields, compared RELATIVE to libavcodec's base offset (`PocBase`). The base is + // established from the CURRENT picture's own count, which is the one POC both sides + // certainly report for the same picture, and then required of every POC after it. + let poc_at = offset_of!(PicParamsH264, CurrFieldOrderCnt); + poc.check( + au, + "CurrFieldOrderCnt[0]", + i32_at(&sub.pic_params, poc_at), + i32_at(theirs, poc_at), + &mut findings, + ); + poc.check( + au, + "CurrFieldOrderCnt[1]", + i32_at(&sub.pic_params, poc_at + 4), + i32_at(theirs, poc_at + 4), + &mut findings, + ); + let mut their_entries = h264_ref_entries(theirs); + shift_poc(&mut their_entries, poc.offset(), true); + compare_ref_array( + au, + "RefFrameList", + &h264_ref_entries(&sub.pic_params), + &their_entries, + &mut mapping, + &mut findings, + ); + // `CurrPic` needs no matching: the same AU is the same picture on both sides. It is fed + // through the mapping under the current picture's own key, so that when this picture + // shows up as a REFERENCE later, the two surfaces are checked against this pairing. + let curr = offset_of!(PicParamsH264, CurrPic); + let frame_num = offset_of!(PicParamsH264, frame_num); + if sub.pic_params[curr] & 0x80 != theirs[curr] & 0x80 { + findings.note( + "CurrPic[AssociatedFlag]", + au, + format!( + "ours {:#04x}, libav {:#04x} — the bottom-field flag, which is 0 for every \ + picture inside this backend's progressive envelope", + sub.pic_params[curr], theirs[curr] + ), + ); + } + let key = ( + false, + u16_at(&sub.pic_params, frame_num), + i32_at(&sub.pic_params, poc_at), + i32_at(&sub.pic_params, poc_at + 4), + ); + mapping.observe( + au, + "CurrPic", + &[(key, (sub.pic_params[curr] & 0x7F, theirs[curr] & 0x7F))], + &mut findings, + ); + } + findings +} + +/// One HEVC RPS index array, resolved through its own side's `RefPicList` into the pictures it +/// names — which is the only form in which the two sides' arrays are comparable. +fn hevc_rps_pictures(pp: &[u8], array: usize, entries: &[(u8, RefEntry)]) -> Vec> { + let list = offset_of!(PicParamsHevc, RefPicList); + (0..8) + .map(|i| { + let index = pp[array + i]; + // The array holds an index INTO `RefPicList` (15 entries), so anything outside that + // — the `0xFF` sentinel included — names nothing, and resolving it is how a stale or + // out-of-range index becomes a reported difference rather than a garbage read. + if usize::from(index) >= 15 { + return None; + } + let slot = pp[list + usize::from(index)]; + if slot == UNUSED_ENTRY { + return None; + } + entries + .iter() + .find(|(s, _)| *s == slot & 0x7F) + .map(|&(_, entry)| entry) + }) + .collect() +} + +/// The whole HEVC picture-parameter comparison. +fn compare_hevc_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings { + let ranges = field_ranges(HEVC_FIELDS, size_of::()); + let structural = [ + "CurrPic", + // Relative, like H.264's — though libavcodec's HEVC POCs carry no base offset (measured: + // 0 on all 250 AUs of the vendored vector). `PocBase` derives whatever offset exists + // rather than assuming this one, so a future FFmpeg that grows one produces a documented + // line instead of 250 findings. + "CurrPicOrderCntVal", + "RefPicList", + "PicOrderCntValList", + "RefPicSetStCurrBefore", + "RefPicSetStCurrAfter", + "RefPicSetLtCurr", + ]; + let mut findings = Findings::default(); + let mut mapping = SurfaceMapping::default(); + let mut poc = PocBase::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 AU", + ); + 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, + hevc_allowance, + &mut findings, + ); + let poc_at = offset_of!(PicParamsHevc, CurrPicOrderCntVal); + poc.check( + au, + "CurrPicOrderCntVal", + i32_at(&sub.pic_params, poc_at), + i32_at(theirs, poc_at), + &mut findings, + ); + let our_entries = hevc_ref_entries(&sub.pic_params); + let mut their_entries = hevc_ref_entries(theirs); + // HEVC's entries carry one POC each, in `top`; `bottom` is a placeholder. + shift_poc(&mut their_entries, poc.offset(), false); + compare_ref_array( + au, + "RefPicList", + &our_entries, + &their_entries, + &mut mapping, + &mut findings, + ); + for (name, offset) in [ + ( + "RefPicSetStCurrBefore", + offset_of!(PicParamsHevc, RefPicSetStCurrBefore), + ), + ( + "RefPicSetStCurrAfter", + offset_of!(PicParamsHevc, RefPicSetStCurrAfter), + ), + ( + "RefPicSetLtCurr", + offset_of!(PicParamsHevc, RefPicSetLtCurr), + ), + ] { + let ours_named = hevc_rps_pictures(&sub.pic_params, offset, &our_entries); + let theirs_named = hevc_rps_pictures(theirs, offset, &their_entries); + for (position, (a, b)) in ours_named.iter().zip(&theirs_named).enumerate() { + if a != b { + findings.note( + format!("{name}[{position}]"), + au, + format!("ours names {a:?}, libav names {b:?}"), + ); + } + } + } + let curr = offset_of!(PicParamsHevc, CurrPic); + let key = (false, 0u16, i32_at(&sub.pic_params, poc_at), 0); + mapping.observe( + au, + "CurrPic", + &[(key, (sub.pic_params[curr] & 0x7F, theirs[curr] & 0x7F))], + &mut findings, + ); + } + findings +} + +/// The quantization-matrix comparison: presence FIRST, then contents by field. +fn compare_qmatrix( + ours: &[OurSubmission], + capture: &Capture, + fields: &[(&'static str, usize)], + total: usize, +) -> Findings { + let ranges = field_ranges(fields, total); + let mut findings = Findings::default(); + for (au, sub) in ours.iter().enumerate() { + let Some(theirs) = capture.qmatrix.get(&au) else { + findings.note( + "", + au, + "the capture reports the matrix neither present nor `absent` for this AU — the \ + PFQM patch (recipe step 4) is missing", + ); + continue; + }; + match (&sub.qmatrix, theirs) { + (None, None) => {} + (Some(_), None) => findings.note( + "", + au, + "we submit an inverse-quantization-matrix buffer where libavcodec submits NONE — \ + for HEVC this is review 13's defect: the picture parameters have told the \ + driver to ignore the matrix, and a driver that honours it anyway dequantizes \ + every residual against it", + ), + (None, Some(_)) => findings.note( + "", + au, + "libavcodec submits an inverse-quantization-matrix buffer and we submit none — \ + the hardware is left to dequantize against whatever it last held", + ), + (Some(mine), Some(theirs)) => { + if mine.len() != theirs.len() { + findings.note( + "", + au, + format!("ours {} bytes, libav {}", mine.len(), theirs.len()), + ); + continue; + } + for (name, range) in &ranges { + if mine[range.clone()] != theirs[range.clone()] { + findings.note( + *name, + au, + byte_diff_detail(&mine[range.clone()], &theirs[range.clone()]), + ); + } + } + } + } + } + findings +} + +/// A descriptor buffer type as a name, for reports. +fn buffer_name(buffer_type: u32) -> &'static str { + match buffer_type { + BUFFER_PICTURE_PARAMETERS => "PICTURE_PARAMETERS", + BUFFER_INVERSE_QUANTIZATION_MATRIX => "INVERSE_QUANTIZATION_MATRIX", + BUFFER_SLICE_CONTROL => "SLICE_CONTROL", + BUFFER_BITSTREAM => "BITSTREAM", + _ => "", + } +} + +/// The descriptor comparison. Everything but the bitstream buffer's `DataSize` must match +/// exactly; that one field has a legitimate divergence class, which is classified apart rather +/// than reported as one undifferentiated difference (module docs). +fn compare_descriptors(ours: &[OurSubmission], capture: &Capture) -> Findings { + let mut findings = Findings::default(); + for (au, sub) in ours.iter().enumerate() { + let Some(theirs) = capture.descriptors.get(&au) else { + findings.note( + "", + au, + "the capture holds no PFBD lines for this AU", + ); + continue; + }; + let our_types: Vec = sub.descriptors.iter().map(|d| d.buffer_type).collect(); + let their_types: Vec = theirs.iter().map(|d| d.buffer_type).collect(); + if our_types != their_types { + // A missing BITSTREAM on their side is the one shape that is a MISSED PATCH rather + // than a divergence — the bitstream descriptor is the one libavcodec fills outside + // the choke point (recipe step 2) — so it is named as such. + let detail = if !their_types.contains(&BUFFER_BITSTREAM) { + "the capture carries no BITSTREAM descriptor at all: recipe step 2's SECOND \ + patch site (the inline fill in commit_bitstream_and_slice_buffer) was missed" + .to_string() + } else { + format!( + "ours {:?}, libav {:?}", + our_types + .iter() + .map(|&t| buffer_name(t)) + .collect::>(), + their_types + .iter() + .map(|&t| buffer_name(t)) + .collect::>() + ) + }; + findings.note("", au, detail); + } + for our_desc in &sub.descriptors { + let name = buffer_name(our_desc.buffer_type); + let Some(their_desc) = theirs + .iter() + .find(|d| d.buffer_type == our_desc.buffer_type) + else { + continue; // already reported by the set comparison + }; + if our_desc.data_offset != their_desc.data_offset { + findings.note( + format!("{name}.DataOffset"), + au, + format!( + "ours {}, libav {}", + our_desc.data_offset, their_desc.data_offset + ), + ); + } + if our_desc.num_mbs_in_buffer != their_desc.num_mbs_in_buffer { + findings.note( + format!("{name}.NumMBsInBuffer"), + au, + format!( + "ours {}, libav {} — this field cannot legitimately differ: \ + mb_width*mb_height on H.264's bitstream and slice-control buffers, 0 \ + everywhere else", + our_desc.num_mbs_in_buffer, their_desc.num_mbs_in_buffer + ), + ); + } + if our_desc.data_size == their_desc.data_size { + continue; + } + if our_desc.buffer_type != BUFFER_BITSTREAM { + findings.note( + format!("{name}.DataSize"), + au, + format!( + "ours {}, libav {} — a fixed-size buffer ({} is `sizeof` a structure or \ + slices * 10), so this cannot legitimately differ", + our_desc.data_size, their_desc.data_size, name + ), + ); + continue; + } + // The bitstream buffer. Their slice COUNT is readable from their slice-control + // size, which is what separates "the two sides split the AU differently" from "the + // two sides delimit each slice a couple of bytes apart". (Both codecs' short + // records are TEN bytes — asserted in + // `the_slice_control_descriptor_is_one_ten_byte_short_format_record_per_slice_for_both_codecs` + // — so one divisor serves both; a slice-control buffer that is NOT a multiple of it + // has already been reported as a `SLICE_CONTROL.DataSize` difference above.) + let their_slices = theirs + .iter() + .find(|d| d.buffer_type == BUFFER_SLICE_CONTROL) + .map(|d| d.data_size as usize / size_of::()); + let our_slices = sub.records.len(); + match their_slices { + Some(count) if count != our_slices => findings.note( + "BITSTREAM.DataSize[slice count]", + au, + format!( + "ours {} bytes over {our_slices} slices, libav {} over {count} — the two \ + sides disagree about how many slices this AU has, which voids the size \ + comparison and is the finding itself", + our_desc.data_size, their_desc.data_size + ), + ), + _ if their_desc.data_size % 128 != 0 => findings.note( + "BITSTREAM.DataSize[unpadded]", + au, + format!( + "libav's {} is not a multiple of 128, which means its tail padding was \ + clamped by a mapping too small for the AU", + their_desc.data_size + ), + ), + _ => { + // Classify on the UNPADDED sizes, which is the only way a small difference + // can be told from a large one: padding rounds up to 128, so an eight-byte + // delimitation difference shows as a delta of 0 or of a whole 128 depending + // on which side of a granule the two land. Theirs is not captured directly, + // but padding is 1..=128 bytes, so it lies in a known window — and the + // difference is legitimate exactly when that window reaches to within four + // bytes per slice of our own unpadded size. + let delta = i64::from(our_desc.data_size) - i64::from(their_desc.data_size); + let tolerance = 4 * our_slices.max(1) as i64; + let their_low = i64::from(their_desc.data_size) - 128; + let their_high = i64::from(their_desc.data_size) - 1; + let ours_unpadded = i64::from(sub.unpadded); + let legitimate = their_low <= ours_unpadded + tolerance + && ours_unpadded - tolerance <= their_high; + findings.note( + if legitimate { + "BITSTREAM.DataSize[delimitation]" + } else { + "BITSTREAM.DataSize" + }, + au, + format!( + "ours {} (unpadded {ours_unpadded}), libav {} (unpadded \ + {their_low}..={their_high}), delta {delta} over {our_slices} \ + slices — {}", + our_desc.data_size, + their_desc.data_size, + if legitimate { + "within the trailing-zero delimitation class the module docs \ + describe, but read it once rather than assume it" + } else { + "OUTSIDE the legitimate delimitation class: too large to be \ + trailing zeros" + } + ), + ); + } + } + } + } + findings +} + +// --------------------------------------------------------------------------- +// This side, in the capture's own format +// --------------------------------------------------------------------------- + +/// Write our submissions as the capture format, so a dump of ours and a capture of libav's can +/// be diffed by any tool — and so the parser above is exercised against a writer that shares no +/// code with it. +fn dump(codec: &str, ours: &[OurSubmission]) -> String { + let mut text = String::new(); + let raw = pf_dxvadec::short_slice_config(match codec { + "h264" => Codec::H264, + _ => Codec::H265, + }); + let _ = writeln!(text, "PFCFG {codec} 0 {raw}"); + for (au, sub) in ours.iter().enumerate() { + let _ = writeln!(text, "PFPP {codec} {au} {}", to_hex(&sub.pic_params)); + match &sub.qmatrix { + Some(qm) => { + let _ = writeln!(text, "PFQM {codec} {au} {}", to_hex(qm)); + } + None => { + let _ = writeln!(text, "PFQM {codec} {au} absent"); + } + } + for desc in &sub.descriptors { + let _ = writeln!( + text, + "PFBD {codec} {au} {} {} {} {}", + desc.buffer_type, desc.data_size, desc.num_mbs_in_buffer, desc.data_offset + ); + } + } + text +} + +// =========================================================================== +// CPU-provable: no capture, ordinary CI +// =========================================================================== + +/// Every hand-declared struct's field table must tile it exactly — no gap anywhere, and nothing +/// left over at the end. +/// +/// Two jobs in one. It is what makes the reports above name the right field (a gap would name the +/// wrong one, or none at all). And it is the AUDIT the twelve-vs-ten slice-record defect asked +/// for, in executable form: a struct tiled exactly by its members has no padding, interior OR +/// tail, so its Rust layout is the C declaration under 1-byte packing — which is how `dxva.h` +/// declares all six. The slice records are in the list precisely because they are the pair that +/// got it wrong; the other four were confirmed against libavcodec's runtime `sizeof` as well. +#[test] +fn every_hand_declared_dxva_struct_is_tiled_exactly_by_its_fields() { + for (what, fields, total) in [ + ("PicParamsH264", H264_FIELDS, size_of::()), + ("PicParamsHevc", HEVC_FIELDS, size_of::()), + ("QmatrixH264", H264_QMATRIX_FIELDS, size_of::()), + ("QmatrixHevc", HEVC_QMATRIX_FIELDS, size_of::()), + ( + "SliceH264Short", + H264_SLICE_FIELDS, + size_of::(), + ), + ( + "SliceHevcShort", + HEVC_SLICE_FIELDS, + size_of::(), + ), + ] { + assert_eq!(fields[0].1, 0, "{what}: the first field must start at 0"); + let ranges = field_ranges(fields, total); + let mut next = 0usize; + for (name, range) in &ranges { + assert_eq!( + range.start, next, + "{what}: {name} leaves a gap — the struct has no interior padding, so \ + consecutive offsets must tile it" + ); + assert!(range.end > range.start, "{what}: {name} is empty"); + next = range.end; + } + assert_eq!(next, total, "{what}: the table stops short of the struct"); + } +} + +#[test] +fn every_h264_au_submits_four_buffers_in_libavcodecs_order() { + for (au, sub) in our_h264_submissions().iter().enumerate() { + assert_eq!( + sub.descriptors + .iter() + .map(|d| d.buffer_type) + .collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_INVERSE_QUANTIZATION_MATRIX, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ], + "AU {au}" + ); + } +} + +#[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 + // H.264 path writes `h->mb_width * h->mb_height`, on the exact call this codebase has + // already seen an Intel driver reject a hand-built variant on. The vendored vector is + // 320x240 — 20x15 macroblocks. + for (au, sub) in our_h264_submissions().iter().enumerate() { + assert_eq!(sub.mb_count, 20 * 15, "AU {au}"); + for desc in &sub.descriptors { + let expected = match desc.buffer_type { + BUFFER_BITSTREAM | BUFFER_SLICE_CONTROL => sub.mb_count, + _ => 0, + }; + assert_eq!( + desc.num_mbs_in_buffer, + expected, + "AU {au}, {}", + buffer_name(desc.buffer_type) + ); + } + } +} + +#[test] +fn every_h264_au_submits_the_quantization_matrix_buffer() { + // H.264's predicate is UNCONDITIONAL in libavcodec (it passes `&ctx_pic->qm` with + // `sizeof(qm)` every time), and the PPS's lists are always meaningful because the vendored + // parser has applied Table 7-2's fallback rules. This is the codec where omitting the + // buffer would be the defect — the mirror image of HEVC's. + for (au, sub) in our_h264_submissions().iter().enumerate() { + assert!(sub.qmatrix.is_some(), "AU {au}"); + let desc = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_INVERSE_QUANTIZATION_MATRIX) + .unwrap_or_else(|| panic!("AU {au} submits no matrix buffer")); + assert_eq!(desc.data_size, size_of::() as u32); + assert_eq!(desc.num_mbs_in_buffer, 0); + } +} + +#[test] +fn the_whole_vendored_hevc_vector_omits_the_quantization_matrix_buffer() { + // Case 1 of the three the qmatrix predicate has: `scaling_list_enabled_flag` clear. The + // buffer is not submitted AT ALL — three descriptors, not four with an empty one. + let ours = our_hevc_submissions(); + for (au, sub) in ours.iter().enumerate() { + assert!(sub.qmatrix.is_none(), "AU {au}"); + assert_eq!( + sub.descriptors + .iter() + .map(|d| d.buffer_type) + .collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ], + "AU {au}" + ); + // …and the picture parameters say so, which is the predicate libavcodec reads. + let flags = u32_at( + &sub.pic_params, + offset_of!(PicParamsHevc, dwCodingParamToolFlags), + ); + assert_eq!(flags & 1, 0, "AU {au}: scaling_list_enabled_flag"); + } +} + +#[test] +fn no_hevc_descriptor_ever_carries_a_macroblock_count() { + // The asymmetry, over the whole vector: libavcodec's HEVC path writes 0 where its H.264 + // path writes mb_width*mb_height, so a CTB count here would be a fresh divergence in the + // other direction. + for (au, sub) in our_hevc_submissions().iter().enumerate() { + for desc in &sub.descriptors { + assert_eq!( + desc.num_mbs_in_buffer, + 0, + "AU {au}, {}", + buffer_name(desc.buffer_type) + ); + } + } +} + +#[test] +fn every_descriptor_of_both_codecs_starts_at_offset_zero_and_names_a_distinct_buffer() { + for (codec, subs) in [ + ("h264", our_h264_submissions()), + ("hevc", our_hevc_submissions()), + ] { + for (au, sub) in subs.iter().enumerate() { + let mut seen = BTreeSet::new(); + for desc in &sub.descriptors { + assert_eq!(desc.data_offset, 0, "{codec} AU {au}"); + assert!( + seen.insert(desc.buffer_type), + "{codec} AU {au}: buffer type {} submitted twice", + desc.buffer_type + ); + assert!(desc.data_size > 0, "{codec} AU {au}: an empty buffer"); + } + } + } +} + +#[test] +fn the_bitstream_descriptor_is_the_packers_padded_size_and_the_slice_records_tile_it_exactly() { + // The internal consistency a driver reads: every `BSNALunitDataLocation` lands inside the + // buffer, the records are contiguous from byte 0, and the last one ends EXACTLY at + // `DataSize` — which is what makes the tail padding charged to it rather than dangling past + // the end (see pack.rs's module docs). + for (codec, subs) in [ + ("h264", our_h264_submissions()), + ("hevc", our_hevc_submissions()), + ] { + for (au, sub) in subs.iter().enumerate() { + let bitstream = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_BITSTREAM) + .unwrap_or_else(|| panic!("{codec} AU {au} submits no bitstream buffer")); + assert_eq!(bitstream.data_size % 128, 0, "{codec} AU {au}: padded size"); + assert!( + bitstream.data_size >= sub.unpadded, + "{codec} AU {au}: {} bytes of slices in a {}-byte buffer", + sub.unpadded, + bitstream.data_size + ); + assert!( + bitstream.data_size - sub.unpadded <= 128, + "{codec} AU {au}: {} bytes of padding", + bitstream.data_size - sub.unpadded + ); + assert!(!sub.records.is_empty(), "{codec} AU {au}: no slices"); + let mut cursor = 0u32; + for (i, record) in sub.records.iter().enumerate() { + assert_eq!( + record.location, cursor, + "{codec} AU {au}: slice {i} location" + ); + assert!( + record.bytes > 3, + "{codec} AU {au}: slice {i} is start code only" + ); + cursor = record.location + record.bytes; + assert!( + cursor <= bitstream.data_size, + "{codec} AU {au}: slice {i} runs past DataSize" + ); + } + assert_eq!( + cursor, bitstream.data_size, + "{codec} AU {au}: the records must tile the whole buffer, padding included" + ); + } + } +} + +#[test] +fn the_slice_control_descriptor_is_one_ten_byte_short_format_record_per_slice_for_both_codecs() { + // Two facts in one, both measured against libavcodec on the RTX 4090 box rather than + // derived: + // + // * **The short record is TEN bytes.** The capture's slice-control `DataSize` is 20 on the + // H.264 vector, which is two slices per picture, and 10 on the HEVC vector, which is one + // slice segment per picture — two codecs, two slice counts, one record size. `dxva.h` + // packs these wire structures to a byte; a `#[repr(C)]` `{u32, u32, u16}` would be twelve + // and would displace every record after the first (see `dxva.rs`'s alignment section). + // * **The `ConfigBitstreamRaw` hazard**: short format is 2 for H.264 and 1 for HEVC — one + // number with two spellings — and these are the short records for both. A buffer sized for + // one format against a config negotiated for the other is a driver reading a different + // struct at every offset. + assert_eq!(pf_dxvadec::short_slice_config(Codec::H264), 2); + assert_eq!(pf_dxvadec::short_slice_config(Codec::H265), 1); + assert_eq!(size_of::(), 10); + assert_eq!(size_of::(), 10); + for (codec, subs, slices_per_picture, capture_data_size) in [ + ("h264", our_h264_submissions(), 2usize, 20u32), + ("hevc", our_hevc_submissions(), 1, 10), + ] { + for (au, sub) in subs.iter().enumerate() { + let control = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_SLICE_CONTROL) + .unwrap_or_else(|| panic!("{codec} AU {au} submits no slice-control buffer")); + assert_eq!( + control.data_size as usize, + 10 * sub.records.len(), + "{codec} AU {au}" + ); + // The vectors' slice counts, so the record size above is anchored to the capture's + // own number rather than to an arithmetic identity: if our splitter ever produced a + // different slice count for these streams, the two sides would stop being + // comparable and 10 would no longer follow from 20 and 10. + assert_eq!( + sub.records.len(), + slices_per_picture, + "{codec} AU {au}: the vendored vector is {slices_per_picture} slice(s) per picture" + ); + assert_eq!( + control.data_size, capture_data_size, + "{codec} AU {au}: libavcodec's captured slice-control DataSize" + ); + } + } +} + +#[test] +fn the_tail_padding_is_charged_to_the_last_slice_record_and_to_no_other() { + // libavcodec's `commit_bitstream_and_slice_buffer` zero-fills + // `FFMIN(128 - ((current - dxva_data) & 127), end - current)` bytes and then does + // `slice->SliceBytesInBuffer += padding` — on the FINAL loop iteration's record. So the last + // record's `SliceBytesInBuffer` counts the padding and every earlier record's does not, and + // a driver reading the records back must find them tiling the buffer exactly. + // [`pf_dxvadec::pack`] implements the same rule; this is where it is checked on the real + // vectors, on the H.264 one because that is the one with more than one slice per picture — + // the shape a single-slice vector cannot distinguish. + for (codec, subs) in [ + ("h264", our_h264_submissions()), + ("hevc", our_hevc_submissions()), + ] { + for (au, sub) in subs.iter().enumerate() { + let bitstream = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_BITSTREAM) + .unwrap_or_else(|| panic!("{codec} AU {au} submits no bitstream buffer")); + let padding = bitstream.data_size - sub.unpadded; + assert!( + (1..=128).contains(&padding), + "{codec} AU {au}: {padding} bytes of padding" + ); + let (last, earlier) = sub + .records + .split_last() + .unwrap_or_else(|| panic!("{codec} AU {au}: no slices")); + // Every earlier record stops exactly where the next slice's start code begins, so + // none of them carries any of the padding. + for (i, record) in earlier.iter().enumerate() { + assert_eq!( + record.location + record.bytes, + sub.records[i + 1].location, + "{codec} AU {au}: record {i} does not end where record {} begins", + i + 1 + ); + } + // The last one runs to the end of the buffer… + assert_eq!( + last.location + last.bytes, + bitstream.data_size, + "{codec} AU {au}: the last record must reach DataSize" + ); + // …and stripping the padding off it leaves exactly the slice bytes the packer wrote, + // which is the statement that the padding is in THAT record and nowhere else. + assert_eq!( + sub.records.iter().map(|r| r.bytes).sum::() - padding, + sub.unpadded, + "{codec} AU {au}: the padding is charged more than once, or not at all" + ); + assert!( + last.bytes > padding, + "{codec} AU {au}: the last record is padding only" + ); + } + } +} + +#[test] +fn the_picture_parameter_buffer_is_the_whole_hand_declared_struct_for_both_codecs() { + for (codec, subs, size) in [ + ("h264", our_h264_submissions(), size_of::()), + ("hevc", our_hevc_submissions(), size_of::()), + ] { + for (au, sub) in subs.iter().enumerate() { + assert_eq!(sub.pic_params.len(), size, "{codec} AU {au}"); + assert_eq!( + sub.descriptors[0].buffer_type, BUFFER_PICTURE_PARAMETERS, + "{codec} AU {au}" + ); + assert_eq!( + sub.descriptors[0].data_size as usize, size, + "{codec} AU {au}" + ); + } + } +} + +/// The vector's first HEVC plan with its parameter sets rewritten to a chosen scaling-list +/// shape, converted and packed — the three cases of 7.4.5's activation, at the descriptor level. +/// +/// Only the scaling-list fields move; everything else is the parser's own output, which is what +/// makes the "coded nowhere" case meaningful (`pps.scaling_list` then holds the parser's Table +/// 7-5/7-6 default fill, and `sps.scaling_list` the all-zero `ScalingLists::default()` an +/// uncoded SPS is left with). +fn hevc_case(enabled: bool, sps_coded: Option, pps_coded: Option) -> OurSubmission { + use std::rc::Rc; + + let aus = split_into_aus_h265(TEST_25FPS_H265); + let mut planner = H265Planner::new(); + let mut plan = planner.plan_au(aus[0]).expect("plan"); + + let mut sps = (*plan.sps).clone(); + sps.scaling_list_enabled_flag = enabled; + sps.scaling_list_data_present_flag = sps_coded.is_some(); + if let Some(fill) = sps_coded { + sps.scaling_list.scaling_list_4x4 = [[fill; 16]; 6]; + sps.scaling_list.scaling_list_8x8 = [[fill; 64]; 6]; + sps.scaling_list.scaling_list_16x16 = [[fill; 64]; 6]; + sps.scaling_list.scaling_list_32x32 = [[fill; 64]; 6]; + sps.scaling_list.scaling_list_dc_coef_minus8_16x16 = [i16::from(fill); 6]; + sps.scaling_list.scaling_list_dc_coef_minus8_32x32 = [i16::from(fill); 6]; + } + let mut pps = (*plan.pps).clone(); + pps.scaling_list_data_present_flag = pps_coded.is_some(); + if let Some(fill) = pps_coded { + pps.scaling_list.scaling_list_4x4 = [[fill; 16]; 6]; + pps.scaling_list.scaling_list_8x8 = [[fill; 64]; 6]; + pps.scaling_list.scaling_list_16x16 = [[fill; 64]; 6]; + pps.scaling_list.scaling_list_32x32 = [[fill; 64]; 6]; + pps.scaling_list.scaling_list_dc_coef_minus8_16x16 = [i16::from(fill); 6]; + pps.scaling_list.scaling_list_dc_coef_minus8_32x32 = [i16::from(fill); 6]; + } + plan.sps = Rc::new(sps); + plan.pps = Rc::new(pps); + + let mut slots = SlotMap::new(plan.picture.max_dpb_frames); + let dxva = pf_dxvadec::plan_to_dxva_h265(&plan, &mut slots, 1).expect("convert"); + let mut mapping = vec![0u8; MAPPING_BYTES]; + let packed = pf_dxvadec::pack(aus[0], &dxva.slice_ranges, &mut mapping).expect("pack"); + let unpadded = pf_dxvadec::packed_size(aus[0], &dxva.slice_ranges).expect("size") as u32; + OurSubmission { + pic_params: pf_dxvadec::as_bytes(&dxva.pic_params).to_vec(), + qmatrix: dxva + .qmatrix + .as_ref() + .map(|qm| pf_dxvadec::as_bytes(qm).to_vec()), + descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed), + records: packed.records, + unpadded, + mb_count: 0, + } +} + +#[test] +fn an_hevc_sequence_that_disables_scaling_lists_submits_no_matrix_however_much_is_coded() { + // Case 1 again, with data coded in BOTH parameter sets: the flag decides, not the data. + let sub = hevc_case(false, Some(7), Some(9)); + assert!(sub.qmatrix.is_none()); + assert!(!sub + .descriptors + .iter() + .any(|d| d.buffer_type == BUFFER_INVERSE_QUANTIZATION_MATRIX)); +} + +#[test] +fn an_hevc_sequence_that_enables_scaling_lists_and_codes_them_submits_the_coded_lists() { + // Case 2: the buffer travels, sized to the whole struct, carrying the coded data. + let sub = hevc_case(true, Some(7), Some(9)); + let qm = sub + .qmatrix + .as_ref() + .expect("an enabled sequence submits the matrix"); + assert_eq!(qm.len(), size_of::()); + let desc = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_INVERSE_QUANTIZATION_MATRIX) + .expect("the matrix buffer is in the set"); + assert_eq!(desc.data_size as usize, size_of::()); + assert_eq!(desc.num_mbs_in_buffer, 0); + // The PPS's data wins over the SPS's (7.4.5), which is visible in the bytes themselves. + assert!( + qm.iter().all(|&b| b == 9 || b == 17), + "the PPS's fill of 9 (DC 9 + 8)" + ); + assert_eq!( + sub.descriptors + .iter() + .map(|d| d.buffer_type) + .collect::>(), + vec![ + BUFFER_PICTURE_PARAMETERS, + BUFFER_INVERSE_QUANTIZATION_MATRIX, + BUFFER_BITSTREAM, + BUFFER_SLICE_CONTROL, + ] + ); +} + +#[test] +fn an_hevc_sequence_that_enables_scaling_lists_but_codes_none_submits_the_defaults_not_zeros() { + // Case 3, and the one that is a live defect if it regresses: `scaling_list_enabled_flag` set + // with no scaling-list data in either parameter set is a legal, ordinary shape, and 7.4.5 + // says the Table 7-5/7-6 DEFAULTS apply. FFmpeg's parser seeds those defaults; the vendored + // cros-codecs parser leaves an uncoded SPS all-ZERO — so a conversion that read the SPS + // here would submit a matrix of zeros while bit 0 of dwCodingParamToolFlags told the driver + // it was authoritative, and every residual would dequantize to nothing. + // + // pic_h265.rs checks the CONTENTS against the spec tables transcribed by hand; this checks + // the two facts the descriptor level owns — the buffer is submitted, and what it carries is + // not zeros. + let sub = hevc_case(true, None, None); + let qm = sub + .qmatrix + .as_ref() + .expect("an enabled sequence submits the matrix even with nothing coded"); + let desc = sub + .descriptors + .iter() + .find(|d| d.buffer_type == BUFFER_INVERSE_QUANTIZATION_MATRIX) + .expect("the matrix buffer is in the set"); + assert_eq!(desc.data_size as usize, size_of::()); + assert!( + !qm.iter().all(|&b| b == 0), + "an all-zero matrix dequantizes every residual to nothing" + ); + // Table 7-5's 4x4 lists are flat 16, and the inferred DC is 8 + 8 = 16. + let lists0 = offset_of!(QmatrixHevc, ucScalingLists0); + assert!(qm[lists0..lists0 + 96].iter().all(|&b| b == 16)); + let dc2 = offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID2); + assert!(qm[dc2..dc2 + 6].iter().all(|&b| b == 16)); + let dc3 = offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID3); + assert!(qm[dc3..dc3 + 2].iter().all(|&b| b == 16)); + // …and every 8x8-and-up list carries a real curve rather than zeros. + let lists1 = offset_of!(QmatrixHevc, ucScalingLists1); + let lists3_end = offset_of!(QmatrixHevc, ucScalingListDCCoefSizeID2); + assert!(qm[lists1..lists3_end].iter().all(|&b| b != 0)); +} + +#[test] +fn the_dump_and_the_parser_agree_and_the_comparison_finds_nothing_against_ourselves() { + // The comparators, exercised on real 250-AU data with a known answer. This is not a + // tautology dressed as a test: the writer and the parser share no code, so a format drift + // on either side fails here, and — with the next two tests, which mutate the "capture" and + // require a NAMED finding — it is what keeps this file from reporting a clean bill of + // health while comparing nothing. + let ours = our_h264_submissions(); + let capture = parse_capture(&dump("h264", &ours), "h264"); + preflight( + &capture, + ours.len(), + "h264", + Some(offset_of!(PicParamsH264, Reserved16Bits)), + ); + assert_eq!(capture.pic_params.len(), VENDORED_AUS); + assert_eq!(capture.qmatrix.len(), VENDORED_AUS); + assert_eq!(capture.descriptors.len(), VENDORED_AUS); + for findings in [ + compare_h264_picparams(&ours, &capture), + compare_descriptors(&ours, &capture), + compare_qmatrix( + &ours, + &capture, + H264_QMATRIX_FIELDS, + size_of::(), + ), + ] { + assert!( + findings.is_empty(), + "comparing our own bytes against themselves must find nothing, got {:?}", + findings.fields() + ); + // Nor may anything be DOCUMENTED away: an allowance that fires on identical bytes is an + // allowance that would hide a real difference. + assert!( + findings.documented_fields().is_empty(), + "identical bytes documented a divergence: {:?}", + findings.documented_fields() + ); + } + + let ours = our_hevc_submissions(); + let capture = parse_capture(&dump("hevc", &ours), "hevc"); + // No Reserved16Bits check for HEVC: the ClearVideo workaround is an H.264-only path. + preflight(&capture, ours.len(), "hevc", None); + for findings in [ + compare_hevc_picparams(&ours, &capture), + compare_descriptors(&ours, &capture), + compare_qmatrix( + &ours, + &capture, + HEVC_QMATRIX_FIELDS, + size_of::(), + ), + ] { + assert!( + findings.is_empty(), + "comparing our own HEVC bytes against themselves must find nothing, got {:?}", + findings.fields() + ); + assert!( + findings.documented_fields().is_empty(), + "identical bytes documented a divergence: {:?}", + findings.documented_fields() + ); + } + // 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)); +} + +/// A submission holding only what [`compare_descriptors`] reads, for the bitstream-size +/// classifier: `slices` slice records tiling a `padded`-byte buffer whose slice data (before the +/// tail padding) is `unpadded` bytes. +fn descriptor_only_submission(unpadded: u32, padded: u32, slices: usize) -> OurSubmission { + let each = unpadded / slices as u32; + let mut records: Vec = (0..slices) + .map(|i| SliceRecord { + location: i as u32 * each, + bytes: each, + }) + .collect(); + // The last record carries the remainder and the padding, exactly as the packer charges it. + let last = records.last_mut().expect("at least one slice"); + last.bytes = padded - last.location; + OurSubmission { + pic_params: vec![0u8; size_of::()], + qmatrix: None, + descriptors: vec![ + BufferDescriptor { + buffer_type: BUFFER_BITSTREAM, + data_offset: 0, + data_size: padded, + num_mbs_in_buffer: 300, + }, + BufferDescriptor { + buffer_type: BUFFER_SLICE_CONTROL, + data_offset: 0, + data_size: 10 * slices as u32, + num_mbs_in_buffer: 300, + }, + ], + records, + unpadded, + mb_count: 300, + } +} + +/// Rewrite every `PFPP ` line of a capture through `f`, which receives the AU index and +/// the picture-parameter bytes. The instrument the two absorbers below are tested with: a +/// divergence this harness ABSORBS must be reproducible on demand, or its allowance is untested. +fn map_picparams(text: &str, codec: &str, mut f: impl FnMut(usize, &mut Vec)) -> String { + let prefix = format!("PFPP {codec} "); + let mut out = String::new(); + for line in text.lines() { + match line.strip_prefix(&prefix) { + Some(rest) => { + let (au, hex) = rest.split_once(' ').expect("our own dump is well formed"); + let au: usize = au.parse().expect("a decimal AU index"); + let mut bytes = from_hex(hex).expect("our own dump is hex"); + f(au, &mut bytes); + let _ = writeln!(out, "{prefix}{au} {}", to_hex(&bytes)); + } + None => { + let _ = writeln!(out, "{line}"); + } + } + } + out +} + +/// Add `delta` to every POC an H.264 picture-parameters buffer carries: the current picture's pair +/// and every IN-USE reference entry's (an unused entry's counts are zero on both sides and must +/// stay that way). This is libavcodec's `prev_poc_msb` offset, synthesised. +fn shift_h264_capture_poc(pp: &mut [u8], delta: i32) { + let curr = offset_of!(PicParamsH264, CurrFieldOrderCnt); + let list = offset_of!(PicParamsH264, RefFrameList); + let focl = offset_of!(PicParamsH264, FieldOrderCntList); + for field in [curr, curr + 4] { + let shifted = i32_at(pp, field).wrapping_add(delta); + pp[field..field + 4].copy_from_slice(&shifted.to_le_bytes()); + } + for i in 0..16 { + if pp[list + i] == UNUSED_ENTRY { + continue; + } + for field in [focl + 8 * i, focl + 8 * i + 4] { + let shifted = i32_at(pp, field).wrapping_add(delta); + pp[field..field + 4].copy_from_slice(&shifted.to_le_bytes()); + } + } +} + +/// A one-AU capture of `(buffer type, DataSize, NumMBsInBuffer)` descriptors. +fn descriptor_capture(descs: &[(u32, u32, u32)]) -> String { + let mut text = String::new(); + for (buffer_type, data_size, mbs) in descs { + let _ = writeln!(text, "PFBD h264 0 {buffer_type} {data_size} {mbs} 0"); + } + text +} + +#[test] +fn libavcodecs_constant_poc_base_is_documented_and_anything_else_about_a_poc_is_a_finding() { + // The absorber that lets the H.264 comparison pass against the real capture, and the three + // ways it must NOT absorb. Without this test the POC fields would simply be excluded from the + // comparison, which is the "green gate that proves nothing" shape this program has been bitten + // by: an excluded field cannot report a wrong POC either. + let ours = our_h264_submissions(); + let base = dump("h264", &ours); + + // 1. Every POC offset by FFmpeg's 65536 — what the RTX 4090 capture actually carries. + let shifted = map_picparams(&base, "h264", |_, pp| shift_h264_capture_poc(pp, 65536)); + let findings = compare_h264_picparams(&ours, &parse_capture(&shifted, "h264")); + assert!( + findings.is_empty(), + "a constant POC base is not a finding, got {:?}", + findings.fields() + ); + assert_eq!( + findings.documented_fields(), + vec!["FieldOrderCnt[POC base]"] + ); + + // 2. A base that is neither 0 nor 65536 is unexplained, and unexplained is a finding. + let odd = map_picparams(&base, "h264", |_, pp| shift_h264_capture_poc(pp, 7)); + let findings = compare_h264_picparams(&ours, &parse_capture(&odd, "h264")); + assert_eq!(findings.fields(), vec!["CurrFieldOrderCnt[0][POC base]"]); + assert!(findings.documented_fields().is_empty()); + + // 3. A base that stops holding is a real POC divergence: 65536 everywhere except one AU, + // whose current picture is four counts adrift. + let drifting = map_picparams(&base, "h264", |au, pp| { + shift_h264_capture_poc(pp, if au == 10 { 65536 - 4 } else { 65536 }) + }); + let findings = compare_h264_picparams(&ours, &parse_capture(&drifting, "h264")); + assert_eq!( + findings.fields(), + vec![ + "CurrFieldOrderCnt[0][POC]", + "CurrFieldOrderCnt[1][POC]", + "RefFrameList[set]", + ] + ); + assert_eq!(findings.by_field["CurrFieldOrderCnt[0][POC]"].first_au, 10); +} + +#[test] +fn the_hevc_tiles_flag_allowance_is_exactly_bit_ten_with_tiles_disabled_and_nothing_else() { + // The other absorber, and the reason it is written per-DIFFERENCE rather than per-field: the + // same word carries eighteen other flags, and a difference in any of them — or in bit 10 while + // tiles are ENABLED, where the flag stops being inert — must still be a finding. + let ours = our_hevc_submissions(); + let base = dump("hevc", &ours); + let at = offset_of!(PicParamsHevc, dwCodingSettingPicturePropertyFlags); + let rewrite = |text: &str, mask_off: u32, mask_on: u32| { + map_picparams(text, "hevc", |_, pp| { + let flags = (u32_at(pp, at) & !mask_off) | mask_on; + pp[at..at + 4].copy_from_slice(&flags.to_le_bytes()); + }) + }; + + // Bit 10 clear on libav's side, tiles disabled on both: the documented divergence, which is + // what the real capture carries on all 250 AUs. + let capture = parse_capture(&rewrite(&base, 1 << 10, 0), "hevc"); + let findings = compare_hevc_picparams(&ours, &capture); + assert!( + findings.is_empty(), + "the documented tiles-flag divergence is not a finding, got {:?}", + findings.fields() + ); + assert_eq!( + findings.documented_fields(), + vec!["dwCodingSettingPicturePropertyFlags"] + ); + + // A different bit of the same word is a finding: bit 11 is + // pps_loop_filter_across_slices_enabled_flag, which is not inert at all. + let capture = parse_capture(&rewrite(&base, 1 << 11, 0), "hevc"); + let findings = compare_hevc_picparams(&ours, &capture); + assert_eq!( + findings.fields(), + vec!["dwCodingSettingPicturePropertyFlags"] + ); + assert!(findings.documented_fields().is_empty()); + + // Bit 10 differing while BOTH sides say tiles are enabled: the flag now governs a real tile + // boundary, so the allowance must not apply. + let ours_with_tiles: Vec = ours + .iter() + .map(|sub| { + let mut pp = sub.pic_params.clone(); + let flags = u32_at(&pp, at) | (1 << 7) | (1 << 10); + pp[at..at + 4].copy_from_slice(&flags.to_le_bytes()); + OurSubmission { + pic_params: pp, + qmatrix: sub.qmatrix.clone(), + descriptors: sub.descriptors.clone(), + records: sub.records.clone(), + unpadded: sub.unpadded, + mb_count: sub.mb_count, + } + }) + .collect(); + let capture = parse_capture( + &rewrite(&dump("hevc", &ours_with_tiles), 1 << 10, 1 << 7), + "hevc", + ); + let findings = compare_hevc_picparams(&ours_with_tiles, &capture); + assert_eq!( + findings.fields(), + vec!["dwCodingSettingPicturePropertyFlags"] + ); + assert!(findings.documented_fields().is_empty()); +} + +#[test] +fn a_bitstream_size_difference_is_classified_by_the_unpadded_window_it_implies() { + // The one descriptor field with a legitimate divergence class, and the arithmetic that tells + // the two apart. Ours: 1026 bytes of slice data over two slices, padded to 1152. Their + // padding is not captured, but it is 1..=128 bytes, so a captured 1024 means their slice data + // was 896..=1023 — which reaches to within four bytes per slice of our 1026 (1022 is 4 bytes + // less over two slices), the trailing-zero delimitation shape. A captured 512 cannot: no + // padding puts their slice data anywhere near ours. + let ours = vec![descriptor_only_submission(1026, 1152, 2)]; + + let legitimate = descriptor_capture(&[ + (BUFFER_BITSTREAM, 1024, 300), + (BUFFER_SLICE_CONTROL, 20, 300), + ]); + let findings = compare_descriptors(&ours, &parse_capture(&legitimate, "h264")); + assert_eq!(findings.fields(), vec!["BITSTREAM.DataSize[delimitation]"]); + + let defect = descriptor_capture(&[ + (BUFFER_BITSTREAM, 512, 300), + (BUFFER_SLICE_CONTROL, 20, 300), + ]); + let findings = compare_descriptors(&ours, &parse_capture(&defect, "h264")); + assert_eq!(findings.fields(), vec!["BITSTREAM.DataSize"]); + + // A differing slice count outranks the size: the two sides split the AU differently, which + // voids the size comparison rather than needing a verdict of its own. Three ten-byte records + // where ours has two. + let split = descriptor_capture(&[ + (BUFFER_BITSTREAM, 1024, 300), + (BUFFER_SLICE_CONTROL, 30, 300), + ]); + let findings = compare_descriptors(&ours, &parse_capture(&split, "h264")); + assert_eq!( + findings.fields(), + vec!["BITSTREAM.DataSize[slice count]", "SLICE_CONTROL.DataSize"] + ); + + // And a NumMBsInBuffer difference is never in the legitimate class. + let zeroed = descriptor_capture(&[(BUFFER_BITSTREAM, 1152, 0), (BUFFER_SLICE_CONTROL, 20, 0)]); + let findings = compare_descriptors(&ours, &parse_capture(&zeroed, "h264")); + assert_eq!( + findings.fields(), + vec!["BITSTREAM.NumMBsInBuffer", "SLICE_CONTROL.NumMBsInBuffer"] + ); +} + +#[test] +fn a_changed_scalar_field_is_reported_by_its_name() { + let ours = our_h264_submissions(); + let mut text = dump("h264", &ours); + // Flip `pic_init_qp_minus26` (offset 172) on AU 7 of the "capture". + let offset = offset_of!(PicParamsH264, pic_init_qp_minus26); + text = mutate_capture_byte(&text, 7, offset, 0x5A); + let capture = parse_capture(&text, "h264"); + let findings = compare_h264_picparams(&ours, &capture); + assert_eq!(findings.fields(), vec!["pic_init_qp_minus26"]); + assert_eq!(findings.by_field["pic_init_qp_minus26"].first_au, 7); + + // …and a differing byte the field table does NOT cover is still reported, by raw offset. + // The table tiles both structs (asserted above), so this fallback is unreachable in + // practice; it exists so that a field added to the struct without being added to the table + // cannot pass unnoticed, and it is exercised here with a deliberately truncated table rather + // than left as an unproven claim in the module docs. + let mut findings = Findings::default(); + let mut theirs = ours[0].pic_params.clone(); + theirs[offset] = 0x5A; + compare_scalars( + 0, + &ours[0].pic_params, + &theirs, + &field_ranges(&H264_FIELDS[..2], 4), + &[], + no_allowance, + &mut findings, + ); + let expected = format!(""); + assert_eq!(findings.fields(), vec![expected.as_str()]); +} + +#[test] +fn a_reordered_reference_list_is_no_finding_but_a_changed_one_is() { + // The divergence this harness must NOT report, and the one it must. libavcodec emits its + // reference array in a different order from ours by construction; a set comparison sees + // through that. Dropping a reference — or renumbering one side's surfaces inconsistently — + // must still be caught. + let ours = our_h264_submissions(); + // An AU with at least two references, so a reversal is observable. + let (au, entries) = ours + .iter() + .enumerate() + .map(|(au, sub)| (au, h264_ref_entries(&sub.pic_params))) + .find(|(_, entries)| entries.len() >= 2) + .expect("the vector must reach two references"); + + let reordered = reverse_h264_reference_list(&ours[au].pic_params); + assert_ne!( + reordered, ours[au].pic_params, + "the reversal must change bytes" + ); + let capture = parse_capture( + &with_picparams(&dump("h264", &ours), au, &reordered), + "h264", + ); + let findings = compare_h264_picparams(&ours, &capture); + assert!( + findings.is_empty(), + "a reordered reference list is not a finding, got {:?}", + findings.fields() + ); + + // Now DROP the last reference from that AU: the set differs, and it must be named. + let mut dropped = ours[au].pic_params.clone(); + let list = offset_of!(PicParamsH264, RefFrameList); + let last = entries.len() - 1; + dropped[list + last] = UNUSED_ENTRY; + let used = offset_of!(PicParamsH264, UsedForReferenceFlags); + let cleared = u32_at(&dropped, used) & !(0b11 << (2 * last)); + dropped[used..used + 4].copy_from_slice(&cleared.to_le_bytes()); + let capture = parse_capture(&with_picparams(&dump("h264", &ours), au, &dropped), "h264"); + let findings = compare_h264_picparams(&ours, &capture); + assert!( + findings.fields().contains(&"RefFrameList[set]"), + "a dropped reference must be reported, got {:?}", + findings.fields() + ); +} + +#[test] +fn a_wholly_renumbered_surface_set_is_no_finding_and_an_inconsistent_one_is() { + // The bijection, both ways round. Renumbering EVERY surface index on the capture side is + // exactly what a different frame pool does, and it must pass; renumbering one AU's alone + // breaks the mapping under live pictures and must not. + let ours = our_h264_submissions(); + let base = dump("h264", &ours); + + let renumbered: Vec> = ours + .iter() + .map(|sub| renumber_h264_surfaces(&sub.pic_params, |slot| slot + 8)) + .collect(); + let mut text = base.clone(); + for (au, pp) in renumbered.iter().enumerate() { + text = with_picparams(&text, au, pp); + } + let capture = parse_capture(&text, "h264"); + let findings = compare_h264_picparams(&ours, &capture); + assert!( + findings.is_empty(), + "a consistently renumbered surface set is a bijection, not a finding, got {:?}", + findings.fields() + ); + + // One AU renumbered differently from the rest: the pictures it still holds change surface + // mid-life, which is precisely what a mis-resolved reference looks like. + let au = ours + .iter() + .position(|sub| h264_ref_entries(&sub.pic_params).len() >= 2) + .expect("two references"); + let mut text = base; + for (i, pp) in renumbered.iter().enumerate() { + if i != au { + text = with_picparams(&text, i, pp); + } + } + let capture = parse_capture(&text, "h264"); + let findings = compare_h264_picparams(&ours, &capture); + assert!( + findings + .fields() + .iter() + .any(|f| f.contains("surface mapping")), + "an inconsistent surface numbering must be reported, got {:?}", + findings.fields() + ); +} + +#[test] +fn an_omitted_hevc_matrix_buffer_is_reported_as_a_presence_difference() { + // Review 13's HEVC defect, in the form the harness would have caught it: the capture says + // `absent`, our side submits one. (Built by rewriting the capture rather than the crate, + // because the crate no longer has the defect — which is the point.) + let ours = our_hevc_submissions(); + let mut subs = ours; + subs[3].qmatrix = Some(vec![0u8; size_of::()]); + subs[3].descriptors = vec![ + BufferDescriptor { + buffer_type: BUFFER_PICTURE_PARAMETERS, + data_offset: 0, + data_size: size_of::() as u32, + num_mbs_in_buffer: 0, + }, + BufferDescriptor { + buffer_type: BUFFER_INVERSE_QUANTIZATION_MATRIX, + data_offset: 0, + data_size: size_of::() as u32, + num_mbs_in_buffer: 0, + }, + subs[3].descriptors[1], + subs[3].descriptors[2], + ]; + // The capture is the honest one: no matrix on any AU. + let honest = our_hevc_submissions(); + let capture = parse_capture(&dump("hevc", &honest), "hevc"); + + let findings = compare_qmatrix( + &subs, + &capture, + HEVC_QMATRIX_FIELDS, + size_of::(), + ); + assert_eq!(findings.fields(), vec![""]); + assert_eq!(findings.by_field[""].first_au, 3); + let findings = compare_descriptors(&subs, &capture); + assert_eq!(findings.fields(), vec![""]); +} + +#[test] +fn a_missing_bitstream_descriptor_is_reported_as_a_missed_patch_site_not_a_defect() { + // The one capture-side mistake that is likely, because libavcodec fills the bitstream + // descriptor outside the choke point: three PFBD lines per AU instead of four. The harness + // must name the patch site rather than accuse our submission of dropping a buffer. + let ours = our_h264_submissions(); + let text: String = dump("h264", &ours) + .lines() + .filter(|line| { + // Only the BITSTREAM descriptor, which is the FOURTH token: matching on " 6 " + // anywhere would also delete AU 6's whole descriptor set. + let fields: Vec<&str> = line.split_whitespace().collect(); + !(fields.first() == Some(&"PFBD") && fields.get(3) == Some(&"6")) + }) + .map(|line| format!("{line}\n")) + .collect(); + let capture = parse_capture(&text, "h264"); + let findings = compare_descriptors(&ours, &capture); + assert_eq!(findings.fields(), vec![""]); + assert!( + findings.by_field[""] + .detail + .contains("commit_bitstream_and_slice_buffer"), + "the report must name the patch site, got {:?}", + findings.by_field[""].detail + ); +} + +#[test] +fn an_unreadable_or_short_capture_is_refused_rather_than_partly_compared() { + let ours = our_h264_submissions(); + let good = dump("h264", &ours); + + // A line whose format drifted. + let broken = good.replace("PFPP h264 5 ", "PFPP h264 5 zz"); + let capture = parse_capture(&broken, "h264"); + assert_eq!(capture.unreadable.len(), 1); + + // A capture of the wrong stream length. + let short: String = good + .lines() + .filter(|line| !line.starts_with("PFPP h264 24 ")) + .map(|line| format!("{line}\n")) + .collect(); + let capture = parse_capture(&short, "h264"); + assert_eq!(capture.pic_params.len(), VENDORED_AUS - 1); + assert!(!capture.pic_params.contains_key(&24)); + + // Another codec's lines are not this codec's. + assert!(parse_capture(&good, "hevc").pic_params.is_empty()); + + // A capture logged against a codec context rather than NULL carries FFmpeg's own + // `[h264 @ 0x…] ` prefix, and must still read cleanly. + let prefixed: String = good + .lines() + .map(|line| format!("[h264 @ 0x7ff1c380a200] {line}\n")) + .collect(); + let capture = parse_capture(&prefixed, "h264"); + assert!(capture.unreadable.is_empty()); + assert_eq!(capture.pic_params.len(), VENDORED_AUS); + assert_eq!(capture.descriptors.len(), VENDORED_AUS); +} + +// --------------------------------------------------------------------------- +// Capture-side mutators, for the tests above +// --------------------------------------------------------------------------- + +/// Replace AU `au`'s `PFPP` line with `pp`. +fn with_picparams(text: &str, au: usize, pp: &[u8]) -> String { + let prefix = format!("PFPP h264 {au} "); + let hevc = format!("PFPP hevc {au} "); + text.lines() + .map(|line| { + if line.starts_with(&prefix) { + format!("{prefix}{}\n", to_hex(pp)) + } else if line.starts_with(&hevc) { + format!("{hevc}{}\n", to_hex(pp)) + } else { + format!("{line}\n") + } + }) + .collect() +} + +/// Set one byte of AU `au`'s captured picture parameters. +fn mutate_capture_byte(text: &str, au: usize, offset: usize, value: u8) -> String { + let prefix = format!("PFPP h264 {au} "); + let mut out = String::new(); + for line in text.lines() { + if let Some(hex) = line.strip_prefix(&prefix) { + let mut bytes = from_hex(hex).expect("our own dump is hex"); + bytes[offset] = value; + let _ = writeln!(out, "{prefix}{}", to_hex(&bytes)); + } else { + let _ = writeln!(out, "{line}"); + } + } + out +} + +/// Reverse the in-use entries of an H.264 reference array, carrying each entry's keys and flag +/// bits with it — the reordering libavcodec's own `short_ref`-then-`long_ref` walk produces, +/// synthesised so the set comparison can be tested without a capture. +fn reverse_h264_reference_list(pp: &[u8]) -> Vec { + let mut out = pp.to_vec(); + let list = offset_of!(PicParamsH264, RefFrameList); + let poc = offset_of!(PicParamsH264, FieldOrderCntList); + let nums = offset_of!(PicParamsH264, FrameNumList); + let used_at = offset_of!(PicParamsH264, UsedForReferenceFlags); + let missing_at = offset_of!(PicParamsH264, NonExistingFrameFlags); + let entries = h264_ref_entries(pp); + let slots: Vec = (0..entries.len()).map(|i| pp[list + i]).collect(); + let used = u32_at(pp, used_at); + let missing = u16_at(pp, missing_at); + let mut new_used = used & !((1u32 << (2 * entries.len())) - 1); + let mut new_missing = missing & !((1u16 << entries.len()) - 1); + for (i, source) in (0..entries.len()).rev().enumerate() { + out[list + i] = slots[source]; + out[nums + 2 * i..nums + 2 * i + 2] + .copy_from_slice(&pp[nums + 2 * source..nums + 2 * source + 2]); + out[poc + 8 * i..poc + 8 * i + 8] + .copy_from_slice(&pp[poc + 8 * source..poc + 8 * source + 8]); + new_used |= (used >> (2 * source) & 0b11) << (2 * i); + new_missing |= (missing >> source & 1) << i; + } + out[used_at..used_at + 4].copy_from_slice(&new_used.to_le_bytes()); + out[missing_at..missing_at + 2].copy_from_slice(&new_missing.to_le_bytes()); + out +} + +/// Rewrite every surface index of an H.264 picture-parameters buffer through `f`. +fn renumber_h264_surfaces(pp: &[u8], f: impl Fn(u8) -> u8) -> Vec { + let mut out = pp.to_vec(); + let curr = offset_of!(PicParamsH264, CurrPic); + out[curr] = (out[curr] & 0x80) | (f(out[curr] & 0x7F) & 0x7F); + let list = offset_of!(PicParamsH264, RefFrameList); + for i in 0..16 { + if out[list + i] != UNUSED_ENTRY { + out[list + i] = (out[list + i] & 0x80) | (f(out[list + i] & 0x7F) & 0x7F); + } + } + out +} + +// =========================================================================== +// Capture-dependent: #[ignore]d, and saying why +// =========================================================================== + +/// 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 = "writes a dump: PF_DXVA_DUMP="] -fn dump_our_h264_picture_parameters() { +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 = String::new(); - for (index, (_, bytes)) in our_picparams().into_iter().enumerate() { - text.push_str(&format!("PFPP h264 {index} ")); - for b in bytes { - text.push_str(&format!("{b:02x}")); - } - text.push('\n'); - } + let mut text = dump("h264", &our_h264_submissions()); + text.push_str(&dump("hevc", &our_hevc_submissions())); std::fs::write(&path, text).expect("write the dump"); println!("wrote {path}"); } -/// Diff this crate's picture parameters against a libavcodec capture, reporting the offsets -/// that differ rather than a pass/fail — the module docs list the three divergences that are -/// expected, and an offset outside them is the finding. #[test] -#[ignore = "needs a libavcodec capture: PF_LIBAV_PICPARAMS= (see the module docs)"] +#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_H264= (see the module docs)"] fn our_h264_picture_parameters_match_libavcodecs() { - let path = std::env::var("PF_LIBAV_PICPARAMS") - .expect("PF_LIBAV_PICPARAMS= names a capture (see the module docs)"); - let capture = parse_capture(&std::fs::read_to_string(&path).expect("read the capture")); - let ours = our_picparams(); - assert!(!capture.is_empty(), "{path} holds no PFPP h264 lines"); - assert_eq!( - capture.len(), + let capture = capture_from_env("PF_LIBAV_CAPTURE_H264", "h264") + .expect("PF_LIBAV_CAPTURE_H264= names a capture (see the module docs)"); + let ours = our_h264_submissions(); + preflight( + &capture, ours.len(), - "the capture covers {} AUs and this crate plans {} — the two sides must decode the \ - same elementary stream, split the same way", - capture.len(), - ours.len() - ); - - // Offsets that differ, and on how many AUs: a field that differs on every picture is a - // systematic divergence (read it against the module docs' list), while one that differs - // on a handful is a case-specific defect. - let mut differing: BTreeMap = BTreeMap::new(); - let mut first_example: BTreeMap = BTreeMap::new(); - for ((index, ours), (_, theirs)) in ours.iter().zip(capture.values().enumerate()) { - assert_eq!( - ours.len(), - theirs.len(), - "AU {index}: the capture's picture parameters are {} bytes and ours are {} — the \ - hand-declared layout and the header disagree, which is a finding on its own", - theirs.len(), - ours.len() - ); - for (offset, (a, b)) in ours.iter().zip(theirs).enumerate() { - if a != b { - *differing.entry(offset).or_default() += 1; - first_example.entry(offset).or_insert((*index, *a, *b)); - } - } - } - - if differing.is_empty() { - println!("{} AUs, byte-identical to libavcodec", ours.len()); - return; - } - println!( - "{} differing byte offsets over {} AUs:", - differing.len(), - ours.len() - ); - for (offset, count) in &differing { - let (au, ours, theirs) = first_example[offset]; - println!(" offset {offset:#06x}: {count} AUs, first at AU {au} (ours {ours:#04x}, libav {theirs:#04x})"); - } - panic!( - "picture parameters diverge at {} offsets — check each against the module docs' list \ - of expected divergences before treating it as a defect", - differing.len() + "h264", + Some(offset_of!(PicParamsH264, Reserved16Bits)), ); + compare_h264_picparams(&ours, &capture).verdict("H.264 picture parameters", ours.len()); +} + +#[test] +#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_HEVC= (see the module docs)"] +fn our_hevc_picture_parameters_match_libavcodecs() { + let capture = capture_from_env("PF_LIBAV_CAPTURE_HEVC", "hevc") + .expect("PF_LIBAV_CAPTURE_HEVC= names a capture (see the module docs)"); + let ours = our_hevc_submissions(); + preflight(&capture, ours.len(), "hevc", None); + compare_hevc_picparams(&ours, &capture).verdict("HEVC picture parameters", ours.len()); +} + +#[test] +#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_H264/PF_LIBAV_CAPTURE_HEVC (module docs)"] +fn our_buffer_descriptors_match_libavcodecs() { + let h264 = capture_from_env("PF_LIBAV_CAPTURE_H264", "h264"); + let hevc = capture_from_env("PF_LIBAV_CAPTURE_HEVC", "hevc"); + assert!( + h264.is_some() || hevc.is_some(), + "PF_LIBAV_CAPTURE_H264= and/or PF_LIBAV_CAPTURE_HEVC= name a capture" + ); + if let Some(capture) = h264 { + let ours = our_h264_submissions(); + preflight( + &capture, + ours.len(), + "h264", + Some(offset_of!(PicParamsH264, Reserved16Bits)), + ); + compare_descriptors(&ours, &capture).verdict("H.264 buffer descriptors", ours.len()); + } + if let Some(capture) = hevc { + let ours = our_hevc_submissions(); + preflight(&capture, ours.len(), "hevc", None); + compare_descriptors(&ours, &capture).verdict("HEVC buffer descriptors", ours.len()); + } +} + +#[test] +#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_H264/PF_LIBAV_CAPTURE_HEVC (module docs)"] +fn our_quantization_matrices_match_libavcodecs() { + let h264 = capture_from_env("PF_LIBAV_CAPTURE_H264", "h264"); + let hevc = capture_from_env("PF_LIBAV_CAPTURE_HEVC", "hevc"); + assert!( + h264.is_some() || hevc.is_some(), + "PF_LIBAV_CAPTURE_H264= and/or PF_LIBAV_CAPTURE_HEVC= name a capture" + ); + if let Some(capture) = h264 { + let ours = our_h264_submissions(); + preflight( + &capture, + ours.len(), + "h264", + Some(offset_of!(PicParamsH264, Reserved16Bits)), + ); + compare_qmatrix( + &ours, + &capture, + H264_QMATRIX_FIELDS, + size_of::(), + ) + .verdict("H.264 quantization matrices", ours.len()); + } + if let Some(capture) = hevc { + let ours = our_hevc_submissions(); + preflight(&capture, ours.len(), "hevc", None); + compare_qmatrix( + &ours, + &capture, + HEVC_QMATRIX_FIELDS, + size_of::(), + ) + .verdict("HEVC quantization matrices", ours.len()); + } }