Every AV1 frame either decode rung has ever been measured against is `tile_cols = tile_rows = 1`. The vendored vector is single-tile on all 274 of its frames, so every tile array the conversions fill — `tiles.widths`, `tiles.heights`, the per-tile records — had only ever been written at index 0, and a conversion that wrote tile 0 and left the rest zero would pass the whole suite. Our encoder splits 4K into TWO TILE ROWS. **The fixture.** `lowdelay-3840x2160.ivf.av1`, 261 KB, 60 frames — `punktfunk-host spike --source synthetic --codec av1 --width 3840 --height 2160 --fps 60 --seconds 1 --bitrate 1` on .21 (NVENC, RTX 5070 Ti), wrapped to IVF with `ffmpeg -f obu … -c copy` so `common::split_av1_aus` (the vendored parser's own `IvfIterator`) frames it exactly as it frames the vector, with no second splitter that could disagree. **4K is not a size choice, it is the only shape with the property.** Measured on the same box with the same command: 1280x720, 1920x1080 and 2560x1440 all give `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with `width_in_sbs_minus_1 = [59]`, `height_in_sbs_minus_1 = [16, 16]`, and both tiles in ONE Tile Group OBU. 60 frames instead of 120 pays for the resolution: 261 KB, under both the 282 KB H.264 and 270 KB H.265 low-delay fixtures. Goldens are libavcodec's software decode, cross-checked between ffmpeg n8.1.2 (Arch x86_64, libdav1d) and 8.1.1 (Homebrew, macOS arm64, libdav1d) whose 746,496,000-byte raw outputs are BYTE-IDENTICAL, not merely equal per frame. 60 of 60 digests distinct. **AV1's frame accounting is asserted, never derived.** The vendored vector is 250 temporal units carrying 274 coded frames of which 24 are hidden; this stream is 60 units, 60 coded, 60 shown, 0 hidden, 0 `show_existing_frame`, 1 key frame. Neither is the general case, so both parity harnesses now take units / decoded / shown as three independent parameters instead of computing one from another, and the CPU guard states all six numbers. **A CPU gate that needed no hardware at all.** `pic_av1`'s new `a_two_tile_frame_fills_both_row_entries_and_leaves_the_rest_zero` pins the second row entry against its OWN `height_in_sbs_minus_1`, requires the two rows to tile the frame exactly, and requires TWO tile RECORDS out of ONE tile group with rows (0,0) and (1,0) — the transposition a square grid could never reveal — each spanning real bytes. The existing one-tile test asserts index 0 is right and `1..` are zero, which a broken multi-tile conversion also satisfies. ⚠⚠ **This is a file, and on AV1 that distinction has already cost a release.** "250/250 delivered frames bit-identical to libavcodec" was true for the entire period the host was shipping only the FIRST TILE of every 4K frame: the verification ran against a vendored file while the truncation lived in packetisation, and the suite stayed green throughout. This fixture closes the multi-tile gap on the DECODE rungs and closes nothing about fragmentation, reassembly, loss or AU boundaries — the golden header, both module docs and the leg docs all say so, at length, so the next reader does not inherit the same false confidence. Legs: `low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec` on the Vulkan rung (11 ignored legs now) and on the D3D11VA rung, plus two non-ignored CPU tests. Verified: 11/11 Vulkan parity legs on .21 (RTX 5070 Ti, 610.57.04), the new one 60/60 bit-identical; workspace clippy `-D warnings` and `cargo fmt --all --check` clean on .21.
2631 lines
117 KiB
Rust
2631 lines
117 KiB
Rust
//! GPU frame-hash parity tests (WP-D) — the decode legs are `#[ignore]`d
|
||
//! because they need real Vulkan Video hardware; the coherence guards at the
|
||
//! bottom of this file are not, and run in ordinary CI.
|
||
//!
|
||
//! Run on a Vulkan-Video box with:
|
||
//!
|
||
//! ```text
|
||
//! cargo test -p pf-vkdecode --test gpu_parity -- --ignored --nocapture
|
||
//! ```
|
||
//!
|
||
//! (RADV boxes additionally need `RADV_PERFTEST=video_decode` — for AV1 as much as
|
||
//! for the other two, and without it `bring_up` reports "no physical device with
|
||
//! VK_KHR_video_decode_av1", which reads like missing silicon; multi-GPU boxes
|
||
//! pin the vendor with `PF_VKD_SMOKE_VENDOR=0x1002` / `0x10de`, same knob as
|
||
//! the smoke tests. Device bring-up lives in `tests/common/mod.rs`.)
|
||
//!
|
||
//! What they prove: H.264, H.265 and AV1 decoding are all exactly specified — every
|
||
//! conformant decoder must produce bit-identical output — so the vendored 25fps
|
||
//! vector of each codec is decoded through [`VkH264Decoder`] / [`VkH265Decoder`] /
|
||
//! [`VkAv1Decoder`],
|
||
//! every output frame's NV12 planes are read back (`vkCmdCopyImageToBuffer` on the
|
||
//! graphics queue — GPU→CPU is fine in a test; the pool grows TRANSFER_SRC via the
|
||
//! decoders' `PF_VKD_TEST_READBACK` hook), cropped to the display region,
|
||
//! SHA-256-hashed in DISPLAY order and compared against goldens from libavcodec's
|
||
//! SOFTWARE decoder (the reference implementation — provenance in
|
||
//! `data/test-25fps.nv12.sha256`, `data/test-25fps-h265.nv12.sha256` and
|
||
//! `data/test-25fps-av1.nv12.sha256`). ALL
|
||
//! frames are collected, including the tail `flush` delivers, and the frame count
|
||
//! must match libavcodec's too.
|
||
//!
|
||
//! Every leg runs ONE body ([`collect_hashes`]) over `common::TestDecoder`, so the
|
||
//! H.265 and AV1 legs cannot quietly test something weaker than the H.264 one. A box
|
||
//! that decodes only some of the three runs those legs and reports the rest as "no
|
||
//! physical device with VK_KHR_video_decode_…", which is a fact about the box —
|
||
//! and on today's fleet AV1 is the one most likely to say so.
|
||
//!
|
||
//! The two Annex-B codecs run that body TWICE: once over the vendored vector as it
|
||
//! sits, and once over the same vector rewritten to FOUR-byte start codes, which is
|
||
//! what the real host emits on 100% of access units in both codecs (1514/1514
|
||
//! H.264 and 1133/1133 HEVC, measured off the M0 NVENC corpus). Prefix width
|
||
//! carries no information, so both runs must reproduce the same goldens —
|
||
//! and submitting the four-byte form to the driver unchanged is precisely the
|
||
//! defect that made HEVC unplayable on every driver tested. Until these legs
|
||
//! existed no parity vector exercised the form that actually ships. **AV1 has no
|
||
//! such twin and needs none**: OBUs are length-delimited, so there is no start-code
|
||
//! prefix for a driver to mis-skip and no second framing to test (see
|
||
//! `common::split_av1_aus`). Its absence is deliberate.
|
||
//!
|
||
//! # The three legs that decode OUR OWN streams
|
||
//!
|
||
//! [`LOWDELAY_H264`], [`LOWDELAY_H265`] and [`LOWDELAY_AV1`] are not conformance
|
||
//! vectors — they are `punktfunk-host spike` output, vendored because a conformance
|
||
//! vector proves conformance to itself and the encoder we ship behind is a different
|
||
//! stream. Each is here for its own reason:
|
||
//!
|
||
//! * **H.264** caught a defect the vector is structurally blind to — 117 of its 120
|
||
//! access units named one surface as both the decode target and a reference.
|
||
//! * **H.265** is EXEMPT from that defect for a structural reason, and an exemption
|
||
//! with no stream behind it is how the H.264 defect survived two milestones.
|
||
//! * **AV1** is neither: the vendored AV1 vector already aliases on 268 of its 274
|
||
//! frames, so that class was covered. It is here because the vector is ONE TILE on
|
||
//! every frame while our encoder splits 4K into two tile rows, so every tile array
|
||
//! the conversions fill had only ever been exercised at index 0.
|
||
//!
|
||
//! All three are backed by a non-ignored CPU guard asserting the stream still has the
|
||
//! property it was vendored for. ⚠ And all three are FILES. A file fixture says
|
||
//! nothing about packetisation, reassembly or loss — which for AV1 is not a
|
||
//! hypothetical caveat but a recorded failure: this suite reported 250/250 throughout
|
||
//! the period the host was shipping only the first tile of every 4K frame.
|
||
//!
|
||
//! # Why the AV1 leg exists at all
|
||
//!
|
||
//! Because until it did, the AV1 rung had no pixel evidence whatsoever. An
|
||
//! adversarial review of the conversion found four defects — per-frame flags left
|
||
//! unset on all 274 frames, a units error in `LoopRestorationSize`, per-reference
|
||
//! info describing the wrong picture, and zeroed film-grain fields — and every one
|
||
//! of them would have shown as a hash mismatch on frame 0 or shortly after, while
|
||
//! NONE of them failed clippy or the crate's unit tests. Type-checking a struct
|
||
//! conversion cannot tell you the struct describes the right picture; only the
|
||
//! pixels can.
|
||
//!
|
||
//! The readback follows the presenter's exact frame contract: wait the frame's
|
||
//! timeline `value`, round-trip the layout, signal `value + 1` in the SAME
|
||
//! submission, then `release_frame(frame, true)` — and every submission is
|
||
//! host-waited before the next decode, so nothing here races the decode queue.
|
||
//!
|
||
//! Reading a failure: frame 0 is intra-only — if it already mismatches, suspect
|
||
//! the readback geometry (row pitch / crop) or intra decode; mismatches that
|
||
//! only appear on later frames point at inter prediction / DPB management.
|
||
|
||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||
|
||
mod common;
|
||
|
||
use ash::vk;
|
||
use common::TestDecoder;
|
||
use pf_vkdecode::DecodeStatus;
|
||
use pf_vkdecode::DecodedVkFrame;
|
||
use pf_vkdecode::NoopQueueLock;
|
||
use pf_vkdecode::VkAv1Decoder;
|
||
use pf_vkdecode::VkH264Decoder;
|
||
use pf_vkdecode::VkH265Decoder;
|
||
use sha2::Digest;
|
||
|
||
/// Golden SHA-256 per display-order frame of the H.264 vector, from libavcodec
|
||
/// software decode (generation command + ffmpeg version in the file's header).
|
||
const GOLDENS_H264: &str = include_str!("data/test-25fps.nv12.sha256");
|
||
|
||
/// The H.265 twin, cross-checked between two independent FFmpeg builds (header).
|
||
const GOLDENS_H265: &str = include_str!("data/test-25fps-h265.nv12.sha256");
|
||
|
||
/// The AV1 twin: 250 DISPLAYED frames of a 274-coded-frame vector, cross-checked
|
||
/// between two independent FFmpeg builds on two architectures AND against the
|
||
/// per-frame MD5s cros-codecs vendored beside the vector (full provenance in the
|
||
/// file's header — it is the only golden here with a third-party corroboration).
|
||
const GOLDENS_AV1: &str = include_str!("data/test-25fps-av1.nv12.sha256");
|
||
|
||
/// The AV1 vector's FIRST frame, as libavcodec decodes it: the 320x240 render
|
||
/// region, tightly packed NV12, 115200 bytes — the same bytes
|
||
/// [`GOLDENS_AV1`]'s first line hashes.
|
||
///
|
||
/// Hashes tell you a frame is wrong; only pixels tell you HOW. This exists for
|
||
/// [`av1_frame0_pixels_say_which_plane_and_how_badly`], whose whole job is to turn
|
||
/// "FIRST DIVERGENT FRAME = 0" into a class: luma or chroma, a shift or a
|
||
/// difference, a filter's worth of error or a structural one. Frame 0 earns the
|
||
/// 113 KiB because it is intra-only — nothing upstream of it can be blamed — and
|
||
/// because on this rung it is where a divergence appears first.
|
||
///
|
||
/// It cannot drift from the golden set it was cut out of:
|
||
/// [`the_av1_frame0_reference_is_the_first_golden`] re-derives its SHA-256 and
|
||
/// compares, in ordinary CI, with no GPU.
|
||
const AV1_FRAME0: &[u8] = include_bytes!("data/test-25fps-av1.frame0.nv12");
|
||
|
||
/// The ten-bit vector and its goldens. No hardware leg in this file consumes them
|
||
/// yet — the D3D11VA rung is where the ten-bit parity leg currently runs — but the
|
||
/// files live here, beside the other goldens, so the guard that keeps them honest
|
||
/// belongs here too and runs on every platform rather than only on Windows.
|
||
const TEST_MAIN10_H265: &[u8] = include_bytes!("data/test-main10.h265");
|
||
const GOLDENS_MAIN10: &str = include_str!("data/test-main10.p010.sha256");
|
||
|
||
/// **Our own host's H.264**, and the only stream here that is not a conformance
|
||
/// vector — vendored 2026-08-07 because the conformance vector is BLIND to the one
|
||
/// defect this rung had.
|
||
///
|
||
/// `test-25fps.h264` reorders and carries a 7-frame DPB against 2 reference frames,
|
||
/// so a picture the sliding window unmarks is never evicted in the same access unit.
|
||
/// A punktfunk host emits low-delay IPPP with `max_num_reorder_frames = 0` and — this
|
||
/// is the part that matters — NVENC writes `max_num_ref_frames = 3` alongside
|
||
/// `max_dec_frame_buffering = 3`, a DPB exactly as deep as its reference count. 8.2.5's
|
||
/// window then unmarks the oldest reference in the very access unit whose C.4.5.3 bump
|
||
/// evicts it, and the conversion used to release that picture's slot before assigning
|
||
/// the setup one — so `pSetupReferenceSlot` and a reference named the same slot on
|
||
/// **117 of these 120 access units**.
|
||
///
|
||
/// The vector passed 250/250 throughout. This is the stream that could not.
|
||
const LOWDELAY_H264: &[u8] = include_bytes!("data/lowdelay-640x480.h264");
|
||
const GOLDENS_LOWDELAY: &str = include_str!("data/lowdelay-640x480.nv12.sha256");
|
||
|
||
/// The low-delay stream is 120 display frames at 640x480 (no conformance window —
|
||
/// both dimensions are macroblock-aligned, so the coded and display sizes agree).
|
||
const LOWDELAY_FRAME_COUNT: usize = 120;
|
||
const DISPLAY_LOWDELAY: (u32, u32) = (640, 480);
|
||
|
||
/// **Our own host's HEVC**, the twin of [`LOWDELAY_H264`] — and the one vendored to
|
||
/// keep an exemption honest rather than to catch a defect.
|
||
///
|
||
/// H.264 and AV1 both had to defer their slot releases past the decode op because
|
||
/// their planners snapshot the marked DPB BEFORE the marking that retires a picture.
|
||
/// `H265Planner` snapshots AFTER `decode_rps`, so an RPS-dropped picture is never in
|
||
/// the set `RefPicList`/`pReferenceSlots` is built from, and the HEVC conversions
|
||
/// still release inline. That argument is correct — and it was, until this stream,
|
||
/// backed by `test-25fps.h265` (which REORDERS, so it cannot reach the shape at all)
|
||
/// plus one throwaway measurement.
|
||
///
|
||
/// This stream reaches the shape. `sps_max_dec_pic_buffering_minus1 = 4` against four
|
||
/// pictures marked in steady state, `sps_max_num_reorder_pics = 0`: 115 of its 120
|
||
/// access units retire exactly one picture, and **all 115 of them would alias** if
|
||
/// the snapshot moved above `decode_rps`. Measured `removed ∩ dpb_refs` is 0 of 120,
|
||
/// so the exemption is a measurement on our own encoder's output rather than a
|
||
/// re-derivable argument.
|
||
///
|
||
/// ⚠ On THIS rung that counterfactual is about the planner, not about
|
||
/// [`pf_vkdecode::plan_to_vk_h265`]: Vulkan's `pReferenceSlots` is spec-defined as the
|
||
/// slots the decode operation uses, so the conversion binds `plan.rps` — the three
|
||
/// current sets, which `decode_rps` itself derives — and never reads `dpb_refs` at
|
||
/// all. The DXVA rung is the one that binds the whole marked DPB (`RefPicList` is
|
||
/// spec-defined that way, and an RFI long-term anchor must survive in it), so it is
|
||
/// the rung a moved snapshot would actually alias on;
|
||
/// `pf_dxvadec::pic_h265`'s tests drive that counterfactual through the conversion.
|
||
/// What the leg below adds on this rung is the thing no HEVC leg here had: PIXELS
|
||
/// from our own encoder, under a DPB that evicts and reuses a slot on 115 of 120
|
||
/// access units instead of a vector whose reordering keeps eviction slack.
|
||
///
|
||
/// Provenance, the `punktfunk-host spike` command and the two-build ffmpeg
|
||
/// cross-check are in the golden file's header, as for the H.264 sibling.
|
||
const LOWDELAY_H265: &[u8] = include_bytes!("data/lowdelay-640x480.h265");
|
||
const GOLDENS_LOWDELAY_H265: &str = include_str!("data/lowdelay-640x480-h265.nv12.sha256");
|
||
|
||
/// **Our own host's AV1**, and the only stream here with more than ONE TILE.
|
||
///
|
||
/// The vendored AV1 vector already exercises the reference-slot aliasing shape (268 of
|
||
/// its 274 frames), so unlike the H.264 and H.265 siblings this is not vendored to
|
||
/// close that. It closes a different gap: no host-generated AV1 stream was tested at
|
||
/// pixel level anywhere, and our encoder's AV1 is structurally unlike the vector —
|
||
/// `RFI_DPB = 5` references, reference-frame invalidation, and at 4K a split encode
|
||
/// that puts **two tile rows in one frame**.
|
||
///
|
||
/// 4K is not a size choice, it is the only shape that has the property. Measured on
|
||
/// .21, same command at four resolutions: 1280x720, 1920x1080 and 2560x1440 all give
|
||
/// `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with
|
||
/// both tiles in ONE Tile Group OBU. It is paid for with 60 frames instead of 120,
|
||
/// which lands at 261 KB — under both other low-delay fixtures.
|
||
///
|
||
/// ⚠ It is a FILE, and a file is not the wire path. "250/250 bit-identical to
|
||
/// libavcodec" was true for AV1 throughout the period the host was shipping only the
|
||
/// first tile of every 4K frame: that number came from a vendored file while the
|
||
/// truncation lived in packetisation. This fixture gives the multi-tile shape pixel
|
||
/// coverage on the DECODE rungs and says nothing whatever about fragmentation,
|
||
/// reassembly or loss. The golden file's header says the same, at length.
|
||
const LOWDELAY_AV1: &[u8] = include_bytes!("data/lowdelay-3840x2160.ivf.av1");
|
||
const GOLDENS_LOWDELAY_AV1: &str = include_str!("data/lowdelay-3840x2160-av1.nv12.sha256");
|
||
|
||
/// The low-delay AV1 stream's temporal units, DISPLAYED frames and render region.
|
||
///
|
||
/// Units and frames are two constants holding 60 rather than one, and that is
|
||
/// deliberate: for the vendored vector they are 250 and 250 while the CODED count is
|
||
/// 274, and a leg that derived one from the other would be asserting AV1's frame
|
||
/// accounting instead of measuring it.
|
||
const LOWDELAY_AV1_UNIT_COUNT: usize = 60;
|
||
const LOWDELAY_AV1_FRAME_COUNT: usize = 60;
|
||
const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160);
|
||
|
||
/// The HEVC low-delay stream's own frame count and display region.
|
||
///
|
||
/// Deliberately NOT shared with [`LOWDELAY_FRAME_COUNT`]/[`DISPLAY_LOWDELAY`] even
|
||
/// though the two fixtures agree today: they are separate files from separate
|
||
/// encoder configurations, and one regenerated at another size must fail on its own
|
||
/// leg rather than silently redefine the other's geometry. Same reason
|
||
/// [`DISPLAY_H264`] and [`DISPLAY_H265`] are two constants holding 320x240.
|
||
const LOWDELAY_H265_FRAME_COUNT: usize = 120;
|
||
const DISPLAY_LOWDELAY_H265: (u32, u32) = (640, 480);
|
||
|
||
/// The Main 10 vector is 50 display frames.
|
||
const MAIN10_FRAME_COUNT: usize = 50;
|
||
|
||
/// The H.264 vector's display (conformance-window) region; the goldens hash
|
||
/// exactly this as tightly packed NV12.
|
||
const DISPLAY_H264: (u32, u32) = (320, 240);
|
||
|
||
/// The H.265 vector's display region. Its SPS carries NO conformance window at
|
||
/// all, so this is also its coded size (golden header) — the two vectors merely
|
||
/// HAPPEN to share dimensions, which is why [`Readback`] takes the size as a
|
||
/// parameter instead of reading one global pair.
|
||
const DISPLAY_H265: (u32, u32) = (320, 240);
|
||
|
||
/// The AV1 vector's display region — its `render_width` x `render_height`, AV1's
|
||
/// answer to a conformance window, and what [`DecodedVkFrame::crop`] carries on this
|
||
/// rung. Equal to the coded (post-superres) size for this vector, which
|
||
/// [`av1_goldens_and_the_ivf_split_agree_with_the_planner`] pins rather than assumes:
|
||
/// a re-synced vector whose render region shrank would make the readback crop a
|
||
/// region the goldens never hashed.
|
||
const DISPLAY_AV1: (u32, u32) = (320, 240);
|
||
|
||
/// All three vectors' picture format: 8-bit 4:2:0. H.264 is NV12 by envelope
|
||
/// (`derive_caps` wants nothing else), H.265 Main resolves to it from the SPS and
|
||
/// AV1 Main (`seq_profile = 0`, `high_bitdepth = 0`) from the sequence header —
|
||
/// and [`DecodedVkFrame::format`] exists precisely so a pool misconfigured to
|
||
/// P010 fails loudly instead of hashing differently.
|
||
const EXPECTED_FORMAT: vk::Format = pf_vkdecode::NV12;
|
||
|
||
/// Every vendored 25fps vector, in all three codecs, is 250 DISPLAY frames.
|
||
///
|
||
/// For H.264 and H.265 that is also one per access unit. For AV1 it is emphatically
|
||
/// not: its 250 temporal units carry [`AV1_CODED_FRAME_COUNT`] coded frames, 24 of
|
||
/// which are HIDDEN — decoded, referenced by later frames, never shown (the vector
|
||
/// uses no `show_existing_frame`, so they are displayed by no route at all). The
|
||
/// rung delivers one frame per `dpb.outputs` id, so 250 is the number the goldens
|
||
/// carry and the number the parity leg must compare.
|
||
const FRAME_COUNT: usize = 250;
|
||
|
||
/// The AV1 vector's CODED frame count — 24 more than [`FRAME_COUNT`].
|
||
///
|
||
/// Asserted by the CPU guard so the display/coded distinction stays a measured fact
|
||
/// rather than a comment: if a re-sync ever made these two numbers equal, the vector
|
||
/// would have lost its hidden-frame coverage (the exact thing that makes AV1's
|
||
/// multi-frame temporal units worth testing) while every hash still matched.
|
||
const AV1_CODED_FRAME_COUNT: usize = 274;
|
||
|
||
/// The golden file's hash lines (comments and blanks skipped).
|
||
fn golden_hashes(file: &'static str) -> Vec<&'static str> {
|
||
file.lines()
|
||
.map(str::trim)
|
||
.filter(|line| !line.is_empty() && !line.starts_with('#'))
|
||
.collect()
|
||
}
|
||
|
||
/// Refuse a golden set that could make a parity verdict vacuous.
|
||
///
|
||
/// Three ways a comparison can "pass" while proving nothing, all closed here:
|
||
///
|
||
/// - **an empty or short set** — [`assert_bit_identical`] compares `hashes` against
|
||
/// `goldens` pairwise and asserts the lengths match, so a file that lost its
|
||
/// entries to a bad regeneration would agree with a decoder that delivered
|
||
/// nothing. Pinning the count against a constant the CPU guards also re-derive
|
||
/// from the planner closes that.
|
||
/// - **junk that is not a digest** — a truncated or re-formatted line can never
|
||
/// equal a real hash, but a file of blank-looking lines could quietly become a
|
||
/// comparison of nothing.
|
||
/// - **all entries identical** — the one that matters most on a video codec. If
|
||
/// every golden were the same digest, a decoder emitting one frozen frame 250
|
||
/// times would pass, which is precisely the failure mode a broken reference
|
||
/// conversion produces. All four golden sets here are fully distinct (250/250
|
||
/// H.264, 250/250 H.265, 250/250 AV1, 50/50 Main 10), so requiring full
|
||
/// distinctness is not a weak bound.
|
||
fn assert_goldens_are_a_real_set(goldens: &[&str], expected: usize, path: &str) {
|
||
assert_eq!(
|
||
goldens.len(),
|
||
expected,
|
||
"{path} must carry one hash per display frame"
|
||
);
|
||
assert!(
|
||
goldens
|
||
.iter()
|
||
.all(|line| line.len() == 64 && line.bytes().all(|b| b.is_ascii_hexdigit())),
|
||
"{path}: every golden line is a bare lowercase SHA-256 hex digest"
|
||
);
|
||
let distinct = goldens
|
||
.iter()
|
||
.collect::<std::collections::HashSet<_>>()
|
||
.len();
|
||
assert_eq!(
|
||
distinct,
|
||
goldens.len(),
|
||
"{path}: {distinct} of {} goldens are distinct — a set with repeats (and \
|
||
above all a set that is ALL one digest) would let a decoder that froze on \
|
||
a single frame pass parity",
|
||
goldens.len()
|
||
);
|
||
}
|
||
|
||
fn sha256_hex(data: &[u8]) -> String {
|
||
use std::fmt::Write as _;
|
||
sha2::Sha256::digest(data)
|
||
.iter()
|
||
.fold(String::with_capacity(64), |mut out, byte| {
|
||
let _ = write!(out, "{byte:02x}");
|
||
out
|
||
})
|
||
}
|
||
|
||
/// Test-only GPU→CPU readback: one persistently mapped staging buffer plus one
|
||
/// command buffer on the GRAPHICS queue. Each read follows the presenter's
|
||
/// frame contract — wait the frame's timeline `value`, transition the image out
|
||
/// of its video layout, copy, restore the layout, signal `value + 1` in the
|
||
/// same submission — and is host-waited (fence) before returning, so the test
|
||
/// stays fully serialized against the decode queue.
|
||
///
|
||
/// The display size is a CONSTRUCTION parameter, not a module constant: it sizes
|
||
/// the staging buffer and is the crop every read asserts against, and the two
|
||
/// vectors sharing 320x240 today is a coincidence that must not become the next
|
||
/// vector's silent corruption.
|
||
struct Readback {
|
||
device: ash::Device,
|
||
queue: vk::Queue,
|
||
cmd_pool: vk::CommandPool,
|
||
cmd: vk::CommandBuffer,
|
||
fence: vk::Fence,
|
||
buffer: vk::Buffer,
|
||
memory: vk::DeviceMemory,
|
||
mapped: *const u8,
|
||
/// The display region every read copies, and the crop it requires.
|
||
display: (u32, u32),
|
||
/// The picture format the pool must carry. Held here rather than read from a
|
||
/// module constant so the sizing below and the per-frame assertion come from
|
||
/// ONE source — a readback sized for eight bits that then accepted a ten-bit
|
||
/// frame would hash half a picture and blame the decoder.
|
||
format: vk::Format,
|
||
/// 1 for NV12, 2 for the `3PACK16` ten-bit family (its samples are 16-bit
|
||
/// words with the ten bits in the high end — the same layout P010 has, which
|
||
/// is why one golden file serves both this rung and the D3D11VA one).
|
||
bytes_per_sample: u32,
|
||
/// `w * h * 3 / 2 * bytes_per_sample` — the tightly packed frame this buffer
|
||
/// holds.
|
||
frame_bytes: usize,
|
||
}
|
||
|
||
impl Readback {
|
||
/// # Safety
|
||
///
|
||
/// `instance`/`pd`/`device` are live; `graphics_qf` names a queue family a
|
||
/// queue was created on (index 0) whose family supports TRANSFER (GRAPHICS
|
||
/// implies it).
|
||
unsafe fn new(
|
||
instance: &ash::Instance,
|
||
pd: vk::PhysicalDevice,
|
||
device: &ash::Device,
|
||
graphics_qf: u32,
|
||
display: (u32, u32),
|
||
format: vk::Format,
|
||
) -> Self {
|
||
let (width, height) = display;
|
||
// The two-plane copy below halves both dimensions for the chroma plane, so
|
||
// an odd display region would silently drop a chroma row/column.
|
||
assert_eq!(
|
||
(width % 2, height % 2),
|
||
(0, 0),
|
||
"the display region must be chroma-aligned"
|
||
);
|
||
let bytes_per_sample = match format {
|
||
f if f == pf_vkdecode::NV12 => 1,
|
||
f if f == pf_vkdecode::P010 => 2,
|
||
other => panic!("readback has no sample size for {other:?}"),
|
||
};
|
||
let frame_bytes = (width * height * 3 / 2 * bytes_per_sample) as usize;
|
||
|
||
// SAFETY: fn contract — live device, queue 0 of this family exists.
|
||
let queue = unsafe { device.get_device_queue(graphics_qf, 0) };
|
||
let pool_ci = vk::CommandPoolCreateInfo::default()
|
||
.flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER)
|
||
.queue_family_index(graphics_qf);
|
||
// SAFETY: live device; destroyed in `destroy`.
|
||
let cmd_pool = unsafe { device.create_command_pool(&pool_ci, None) }
|
||
.expect("create the readback command pool");
|
||
let alloc_ci = vk::CommandBufferAllocateInfo::default()
|
||
.command_pool(cmd_pool)
|
||
.level(vk::CommandBufferLevel::PRIMARY)
|
||
.command_buffer_count(1);
|
||
// SAFETY: the pool was just created on this device.
|
||
let cmd = unsafe { device.allocate_command_buffers(&alloc_ci) }
|
||
.expect("allocate the readback command buffer")[0];
|
||
// SAFETY: live device; destroyed in `destroy`.
|
||
let fence = unsafe { device.create_fence(&vk::FenceCreateInfo::default(), None) }
|
||
.expect("create the readback fence");
|
||
|
||
let buffer_ci = vk::BufferCreateInfo::default()
|
||
.size(frame_bytes as u64)
|
||
.usage(vk::BufferUsageFlags::TRANSFER_DST)
|
||
.sharing_mode(vk::SharingMode::EXCLUSIVE);
|
||
// SAFETY: live device; destroyed in `destroy`.
|
||
let buffer =
|
||
unsafe { device.create_buffer(&buffer_ci, None) }.expect("create the staging buffer");
|
||
// SAFETY: the buffer was just created on this device.
|
||
let req = unsafe { device.get_buffer_memory_requirements(buffer) };
|
||
// SAFETY: live instance + physical device (fn contract).
|
||
let props = unsafe { instance.get_physical_device_memory_properties(pd) };
|
||
let wanted = vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
|
||
let type_index = (0..props.memory_type_count)
|
||
.find(|&i| {
|
||
req.memory_type_bits & (1u32 << i) != 0
|
||
&& props.memory_types[i as usize]
|
||
.property_flags
|
||
.contains(wanted)
|
||
})
|
||
.expect("a HOST_VISIBLE|HOST_COHERENT memory type for the staging buffer");
|
||
let alloc = vk::MemoryAllocateInfo::default()
|
||
.allocation_size(req.size)
|
||
.memory_type_index(type_index);
|
||
// SAFETY: live device, size from the requirements just queried; freed in
|
||
// `destroy`.
|
||
let memory =
|
||
unsafe { device.allocate_memory(&alloc, None) }.expect("allocate staging memory");
|
||
// SAFETY: fresh buffer bound to fresh memory of the required size.
|
||
unsafe { device.bind_buffer_memory(buffer, memory, 0) }.expect("bind staging memory");
|
||
// SAFETY: the memory is HOST_VISIBLE and not yet mapped; the mapping
|
||
// lives until `destroy` frees the memory (implicit unmap).
|
||
let mapped =
|
||
unsafe { device.map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) }
|
||
.expect("map the staging buffer")
|
||
.cast_const()
|
||
.cast::<u8>();
|
||
|
||
Self {
|
||
device: device.clone(),
|
||
queue,
|
||
cmd_pool,
|
||
cmd,
|
||
fence,
|
||
buffer,
|
||
memory,
|
||
mapped,
|
||
display,
|
||
format,
|
||
bytes_per_sample,
|
||
frame_bytes,
|
||
}
|
||
}
|
||
|
||
/// Copy `frame`'s cropped NV12 planes into the staging buffer and return
|
||
/// them tightly packed (Y `w*h` bytes, then interleaved UV `w*h/2` bytes) —
|
||
/// exactly the layout ffmpeg's `-f rawvideo -pix_fmt nv12` writes, so the
|
||
/// hashes compare 1:1 and row pitch/crop padding can never leak in
|
||
/// (`bufferRowLength = 0` packs rows at the copy extent).
|
||
///
|
||
/// # Safety
|
||
///
|
||
/// `frame` was delivered by a decoder on this device and is not yet
|
||
/// released; its image carries TRANSFER_SRC usage (the decoders'
|
||
/// `PF_VKD_TEST_READBACK` hook); no other work uses the graphics queue or
|
||
/// this frame's image concurrently (the test is fully serialized).
|
||
unsafe fn read_nv12(&self, frame: &DecodedVkFrame) -> Vec<u8> {
|
||
let (width, height) = self.display;
|
||
assert_eq!(
|
||
(frame.crop.width, frame.crop.height),
|
||
self.display,
|
||
"the vector's display size this readback was built for (the goldens \
|
||
hash exactly this region)"
|
||
);
|
||
assert_eq!(
|
||
(frame.crop.x % 2, frame.crop.y % 2),
|
||
(0, 0),
|
||
"chroma-aligned crop origin"
|
||
);
|
||
|
||
let begin = vk::CommandBufferBeginInfo::default()
|
||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
|
||
// SAFETY: the buffer came from a RESET_COMMAND_BUFFER pool (begin
|
||
// implicitly resets) and its previous submission was fence-waited.
|
||
unsafe { self.device.begin_command_buffer(self.cmd, &begin) }
|
||
.expect("begin the readback command buffer");
|
||
|
||
let subresource = vk::ImageSubresourceRange {
|
||
aspect_mask: vk::ImageAspectFlags::COLOR,
|
||
base_mip_level: 0,
|
||
level_count: 1,
|
||
base_array_layer: frame.layer,
|
||
layer_count: 1,
|
||
};
|
||
// Into TRANSFER_SRC: execution/memory dependencies against the decode
|
||
// are carried by the timeline wait at submit (visibility included), so
|
||
// no src access is needed here.
|
||
let to_transfer = vk::ImageMemoryBarrier2::default()
|
||
.src_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)
|
||
.src_access_mask(vk::AccessFlags2::empty())
|
||
.dst_stage_mask(vk::PipelineStageFlags2::COPY)
|
||
.dst_access_mask(vk::AccessFlags2::TRANSFER_READ)
|
||
.old_layout(frame.layout)
|
||
.new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
|
||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.image(frame.image)
|
||
.subresource_range(subresource);
|
||
let dep =
|
||
vk::DependencyInfo::default().image_memory_barriers(std::slice::from_ref(&to_transfer));
|
||
// SAFETY: recording state; the image is live until release (fn contract).
|
||
unsafe { self.device.cmd_pipeline_barrier2(self.cmd, &dep) };
|
||
|
||
// Two plane copies, crop applied at the source (plane-1 offsets/extents
|
||
// are in the R8G8 plane's own half-resolution coordinates), rows packed
|
||
// into the buffer at the copy extent.
|
||
let layers = |aspect| vk::ImageSubresourceLayers {
|
||
aspect_mask: aspect,
|
||
mip_level: 0,
|
||
base_array_layer: frame.layer,
|
||
layer_count: 1,
|
||
};
|
||
let regions = [
|
||
vk::BufferImageCopy {
|
||
buffer_offset: 0,
|
||
buffer_row_length: 0,
|
||
buffer_image_height: 0,
|
||
image_subresource: layers(vk::ImageAspectFlags::PLANE_0),
|
||
image_offset: vk::Offset3D {
|
||
x: frame.crop.x as i32,
|
||
y: frame.crop.y as i32,
|
||
z: 0,
|
||
},
|
||
image_extent: vk::Extent3D {
|
||
width,
|
||
height,
|
||
depth: 1,
|
||
},
|
||
},
|
||
vk::BufferImageCopy {
|
||
// A BYTE offset, unlike the extents above, which are texels: the
|
||
// luma plane occupies `w * h * bytes_per_sample` bytes.
|
||
buffer_offset: u64::from(width * height * self.bytes_per_sample),
|
||
buffer_row_length: 0,
|
||
buffer_image_height: 0,
|
||
image_subresource: layers(vk::ImageAspectFlags::PLANE_1),
|
||
image_offset: vk::Offset3D {
|
||
x: (frame.crop.x / 2) as i32,
|
||
y: (frame.crop.y / 2) as i32,
|
||
z: 0,
|
||
},
|
||
image_extent: vk::Extent3D {
|
||
width: width / 2,
|
||
height: height / 2,
|
||
depth: 1,
|
||
},
|
||
},
|
||
];
|
||
// SAFETY: the image is in TRANSFER_SRC_OPTIMAL via the barrier above and
|
||
// carries TRANSFER_SRC usage (fn contract); the buffer's `frame_bytes`
|
||
// exactly spans the two packed regions.
|
||
unsafe {
|
||
self.device.cmd_copy_image_to_buffer(
|
||
self.cmd,
|
||
frame.image,
|
||
vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
|
||
self.buffer,
|
||
®ions,
|
||
);
|
||
}
|
||
|
||
// Restore the video layout (the presenter contract: the consumer puts
|
||
// the image back exactly as delivered) and make the copy host-readable.
|
||
let restore = vk::ImageMemoryBarrier2::default()
|
||
.src_stage_mask(vk::PipelineStageFlags2::COPY)
|
||
.src_access_mask(vk::AccessFlags2::empty())
|
||
.dst_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)
|
||
.dst_access_mask(vk::AccessFlags2::empty())
|
||
.old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
|
||
.new_layout(frame.layout)
|
||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.image(frame.image)
|
||
.subresource_range(subresource);
|
||
let host_read = vk::BufferMemoryBarrier2::default()
|
||
.src_stage_mask(vk::PipelineStageFlags2::COPY)
|
||
.src_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
|
||
.dst_stage_mask(vk::PipelineStageFlags2::HOST)
|
||
.dst_access_mask(vk::AccessFlags2::HOST_READ)
|
||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||
.buffer(self.buffer)
|
||
.offset(0)
|
||
.size(vk::WHOLE_SIZE);
|
||
let dep = vk::DependencyInfo::default()
|
||
.image_memory_barriers(std::slice::from_ref(&restore))
|
||
.buffer_memory_barriers(std::slice::from_ref(&host_read));
|
||
// SAFETY: recording state; own buffer, live image.
|
||
unsafe { self.device.cmd_pipeline_barrier2(self.cmd, &dep) };
|
||
// SAFETY: recording above is complete and valid.
|
||
unsafe { self.device.end_command_buffer(self.cmd) }.expect("end the readback commands");
|
||
|
||
// Wait `value`, signal `value + 1` — the DecodedVkFrame sync contract.
|
||
let wait = vk::SemaphoreSubmitInfo::default()
|
||
.semaphore(frame.semaphore)
|
||
.value(frame.value)
|
||
.stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS);
|
||
let signal = vk::SemaphoreSubmitInfo::default()
|
||
.semaphore(frame.semaphore)
|
||
.value(frame.value + 1)
|
||
.stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS);
|
||
let cmd_info = vk::CommandBufferSubmitInfo::default().command_buffer(self.cmd);
|
||
let submit = vk::SubmitInfo2::default()
|
||
.wait_semaphore_infos(std::slice::from_ref(&wait))
|
||
.command_buffer_infos(std::slice::from_ref(&cmd_info))
|
||
.signal_semaphore_infos(std::slice::from_ref(&signal));
|
||
// SAFETY: live queue/fence; the semaphore is the frame's timeline
|
||
// semaphore (fn contract), the fence was reset after its last use.
|
||
unsafe {
|
||
self.device
|
||
.queue_submit2(self.queue, std::slice::from_ref(&submit), self.fence)
|
||
}
|
||
.expect("submit the readback");
|
||
// SAFETY: the fence was just submitted.
|
||
unsafe {
|
||
self.device
|
||
.wait_for_fences(&[self.fence], true, 10_000_000_000)
|
||
}
|
||
.expect("readback completes within 10s");
|
||
// SAFETY: the fence was observed signalled above.
|
||
unsafe { self.device.reset_fences(&[self.fence]) }.expect("reset the readback fence");
|
||
|
||
// SAFETY: `mapped` points at `frame_bytes` host-coherent bytes (the
|
||
// buffer was created at that size); the fence wait (plus the HOST_READ
|
||
// barrier) ordered the device writes before this host read.
|
||
unsafe { std::slice::from_raw_parts(self.mapped, self.frame_bytes) }.to_vec()
|
||
}
|
||
|
||
/// # Safety
|
||
///
|
||
/// No submission in flight (every `read_nv12` fence-waited before
|
||
/// returning) and nothing else references these handles.
|
||
unsafe fn destroy(&self) {
|
||
// SAFETY: own handles on the live device, idle per the fn contract;
|
||
// freeing the memory implicitly unmaps it.
|
||
unsafe {
|
||
self.device.destroy_buffer(self.buffer, None);
|
||
self.device.free_memory(self.memory, None);
|
||
self.device.destroy_fence(self.fence, None);
|
||
self.device.destroy_command_pool(self.cmd_pool, None);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wait the frame's decode verdict, read + hash its pixels, release it (with
|
||
/// the presenter write-back the readback enqueued). `index` is the display
|
||
/// index the hash will land at.
|
||
fn consume_frame(
|
||
decoder: &mut impl TestDecoder,
|
||
readback: &Readback,
|
||
frame: &DecodedVkFrame,
|
||
index: usize,
|
||
) -> String {
|
||
assert_eq!(
|
||
decoder.wait_status(frame),
|
||
DecodeStatus::Ok,
|
||
"frame {index}: decode op not COMPLETE\n state: {}",
|
||
decoder.debug_snapshot()
|
||
);
|
||
// A pool built for the wrong picture format would decode correctly and then
|
||
// hash differently for a reason no mismatch report could explain
|
||
// (`DecodedVkFrame::format` docs) — refuse it here instead.
|
||
assert_eq!(
|
||
frame.format, readback.format,
|
||
"frame {index}: the vector must decode into the pool format the readback \
|
||
was built for"
|
||
);
|
||
// SAFETY: the frame is delivered and unreleased on the readback's device;
|
||
// the pool carries TRANSFER_SRC (PF_VKD_TEST_READBACK was set before the
|
||
// decoder's first decode); the test is fully serialized, so nothing else
|
||
// touches the graphics queue or this image.
|
||
let nv12 = unsafe { readback.read_nv12(frame) };
|
||
decoder
|
||
.release_frame(frame, true)
|
||
.unwrap_or_else(|e| panic!("frame {index}: release failed: {e}"));
|
||
sha256_hex(&nv12)
|
||
}
|
||
|
||
/// Decode every AU, hash every delivered frame in display order, including the
|
||
/// tail `flush` hands back. One body for all three codecs.
|
||
///
|
||
/// The flush tail is where the codecs legitimately differ and the body deliberately
|
||
/// does not: H.264 and H.265 can hold pictures back for reorder, so their planners'
|
||
/// `flush` releases a tail. AV1's planner has no `flush` at all — a shown frame is
|
||
/// output by the very temporal unit that decodes it — so `VkAv1Decoder::flush` frees
|
||
/// the hidden pictures' images and hands back nothing. Draining afterwards is
|
||
/// therefore a no-op for AV1 rather than a special case, and running the identical
|
||
/// body means an AV1 rung that ever DID strand a shown frame would be caught by the
|
||
/// frame-count assertion instead of hidden by a codec-specific shortcut.
|
||
fn collect_hashes(
|
||
decoder: &mut impl TestDecoder,
|
||
readback: &Readback,
|
||
aus: &[&[u8]],
|
||
) -> Vec<String> {
|
||
let mut hashes: Vec<String> = Vec::new();
|
||
for (au_index, au) in aus.iter().enumerate() {
|
||
let mut next = decoder.decode(au).unwrap_or_else(|e| {
|
||
panic!(
|
||
"AU {au_index}: decode failed: {e}\n state: {}",
|
||
decoder.debug_snapshot()
|
||
)
|
||
});
|
||
while let Some(frame) = next {
|
||
let hash = consume_frame(decoder, readback, &frame, hashes.len());
|
||
hashes.push(hash);
|
||
next = decoder.take_ready();
|
||
}
|
||
}
|
||
// The decoders emit in bumping (display) order and a stream may hold frames —
|
||
// the flush tail belongs in the comparison too.
|
||
decoder.flush();
|
||
while let Some(frame) = decoder.take_ready() {
|
||
let hash = consume_frame(decoder, readback, &frame, hashes.len());
|
||
hashes.push(hash);
|
||
}
|
||
eprintln!(
|
||
"final state: {} status_queries={}",
|
||
decoder.debug_snapshot(),
|
||
decoder.status_queries()
|
||
);
|
||
hashes
|
||
}
|
||
|
||
/// The verdict, run AFTER teardown so a mismatch panic cannot leave the device
|
||
/// alive.
|
||
fn assert_bit_identical(hashes: &[String], goldens: &[&str], codec: &str) {
|
||
assert_eq!(
|
||
hashes.len(),
|
||
goldens.len(),
|
||
"{codec}: frame count diverges from libavcodec ({} decoded vs {} golden)",
|
||
hashes.len(),
|
||
goldens.len()
|
||
);
|
||
let mut mismatches = 0usize;
|
||
let mut first_divergence: Option<usize> = None;
|
||
for (index, (got, want)) in hashes.iter().zip(goldens.iter()).enumerate() {
|
||
if got.as_str() != *want {
|
||
if mismatches < 10 {
|
||
eprintln!("frame {index}: MISMATCH\n ours: {got}\n golden: {want}");
|
||
}
|
||
first_divergence.get_or_insert(index);
|
||
mismatches += 1;
|
||
}
|
||
}
|
||
// The FIRST divergent index is the whole diagnostic: everything after it may be
|
||
// downstream of that one frame through prediction and the DPB, so a report that
|
||
// only counted mismatches would bury the one number that localises the defect.
|
||
assert!(
|
||
first_divergence.is_none(),
|
||
"{codec}: FIRST DIVERGENT FRAME = {} ({mismatches}/{} frames diverge from \
|
||
libavcodec; up to 10 printed above). Frame 0 is intra-only — if IT is the \
|
||
first, suspect readback geometry (pitch/crop), the picture format, or intra \
|
||
decode / the per-frame parameter conversion; a first divergence LATER points \
|
||
at inter prediction, per-reference info or DPB management, and the frames \
|
||
after it are probably just downstream of it.",
|
||
first_divergence.unwrap_or_default(),
|
||
hashes.len()
|
||
);
|
||
eprintln!(
|
||
"{codec}: {} frames bit-identical to libavcodec software decode",
|
||
hashes.len()
|
||
);
|
||
}
|
||
|
||
/// One H.264 parity run over a caller-supplied AU list.
|
||
///
|
||
/// The AUs are a parameter rather than a constant because two legs share this
|
||
/// body: the vendored vector as it sits (three-byte start codes) and the same
|
||
/// vector rewritten to the four-byte ones the real host actually emits. Both
|
||
/// must reproduce the SAME goldens, because prefix width carries no
|
||
/// information — and running one body twice is what makes that an equality
|
||
/// rather than two similar-looking assertions that could drift apart.
|
||
fn h264_parity_run(aus: &[&[u8]], label: &str) {
|
||
h264_parity_run_against(aus, label, GOLDENS_H264, FRAME_COUNT, DISPLAY_H264);
|
||
}
|
||
|
||
/// [`h264_parity_run`] with its stream's own goldens and geometry, for the legs that
|
||
/// do not decode the vendored vector.
|
||
fn h264_parity_run_against(
|
||
aus: &[&[u8]],
|
||
label: &str,
|
||
goldens: &'static str,
|
||
frame_count: usize,
|
||
display: (u32, u32),
|
||
) {
|
||
// One codec at a time on the device, and the `set_var` below happens only
|
||
// under this lock (see `common::gpu_lock`).
|
||
let _gpu = common::gpu_lock();
|
||
|
||
// The decoder reads this at session creation (first decode call): pool
|
||
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
|
||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||
|
||
let goldens = golden_hashes(goldens);
|
||
assert_eq!(
|
||
goldens.len(),
|
||
frame_count,
|
||
"the golden file carries one hash per libavcodec frame"
|
||
);
|
||
|
||
let setup = common::bring_up(&common::Request {
|
||
codec: common::H264,
|
||
// Unlike the smoke legs this one NEEDS a graphics queue (the readback
|
||
// records there), so a device without one is skipped, not defaulted.
|
||
graphics: common::Graphics::Required,
|
||
report_families: true,
|
||
});
|
||
let handles = setup.handles();
|
||
|
||
let hashes = {
|
||
// SAFETY: `setup` outlives this block (destroyed below, after the decoder
|
||
// and readback drop at the block's end), it was created with the H.264
|
||
// decode extensions + timeline/sync2 features, and its queue fields name
|
||
// the families/queues it created.
|
||
let mut decoder = unsafe { VkH264Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||
.expect("wrap the device");
|
||
// SAFETY: live instance/device; queue 0 of `graphics_qf` was created by
|
||
// the bring-up; destroyed at the end of this block after its last read.
|
||
let readback = unsafe {
|
||
Readback::new(
|
||
&setup.instance,
|
||
setup.pd,
|
||
&setup.device,
|
||
setup.graphics_qf,
|
||
display,
|
||
EXPECTED_FORMAT,
|
||
)
|
||
};
|
||
let hashes = collect_hashes(&mut decoder, &readback, aus);
|
||
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing
|
||
// else references its handles.
|
||
unsafe { readback.destroy() };
|
||
hashes
|
||
};
|
||
|
||
// SAFETY: the decoder is gone (its Drop drained the queue and destroyed its
|
||
// session/pools), the readback's handles are destroyed, and nothing else
|
||
// references the setup's handles.
|
||
unsafe { setup.destroy() };
|
||
|
||
assert_bit_identical(&hashes, &goldens, label);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
|
||
fn h264_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
h264_parity_run(&common::split_h264_aus(common::TEST_25FPS_H264), "H.264");
|
||
}
|
||
|
||
/// The same 250 frames, submitted the way the real host submits them.
|
||
///
|
||
/// A failure here where the leg above passes means the four-byte prefix is
|
||
/// reaching the driver — `ring::pack_slices` stopped trimming the leading zero
|
||
/// byte, or stopped deriving the slice offsets from the trimmed lengths — which
|
||
/// is the defect that made HEVC unplayable on every driver tested.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
|
||
fn h264_four_byte_start_codes_decode_bit_identically() {
|
||
let stream = common::h264_four_byte_start_codes(common::TEST_25FPS_H264);
|
||
h264_parity_run(
|
||
&common::split_h264_aus(&stream),
|
||
"H.264 (4-byte start codes)",
|
||
);
|
||
}
|
||
|
||
/// The leg the vendored vector cannot be: **our own host's low-delay H.264**.
|
||
///
|
||
/// The vector above passed 250/250 on every driver in the fleet while this rung
|
||
/// named one DPB slot as both `pSetupReferenceSlot` and a reference on 117 of the
|
||
/// 120 access units below — the shape it simply never produces (see
|
||
/// [`LOWDELAY_H264`]). Both of this rung's DPB modes take it badly and neither
|
||
/// loudly: DISTINCT hands the aliased reference the same array layer the setup
|
||
/// writes; COINCIDE finds no bound image for it, drops it from `pReferenceSlots` and
|
||
/// `trace!`s. So a leg that decodes what we actually ship is not redundant with the
|
||
/// conformance leg, it is the only one that can see this class at all.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
|
||
fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
h264_parity_run_against(
|
||
&common::split_h264_aus(LOWDELAY_H264),
|
||
"H.264 (low-delay host stream)",
|
||
GOLDENS_LOWDELAY,
|
||
LOWDELAY_FRAME_COUNT,
|
||
DISPLAY_LOWDELAY,
|
||
);
|
||
}
|
||
|
||
/// The H.265 twin of [`h264_parity_run`]; see its docs for why the AUs are a
|
||
/// parameter.
|
||
fn h265_parity_run(
|
||
aus: &[&[u8]],
|
||
goldens_file: &'static str,
|
||
expected_frames: usize,
|
||
bit_depth_luma_minus8: u8,
|
||
format: vk::Format,
|
||
display: (u32, u32),
|
||
label: &str,
|
||
) {
|
||
// As the H.264 leg: one codec at a time, `set_var` under the lock.
|
||
let _gpu = common::gpu_lock();
|
||
|
||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||
|
||
let goldens = golden_hashes(goldens_file);
|
||
assert_eq!(
|
||
goldens.len(),
|
||
expected_frames,
|
||
"the golden file carries one hash per libavcodec frame"
|
||
);
|
||
|
||
let setup = common::bring_up(&common::Request {
|
||
codec: common::H265,
|
||
graphics: common::Graphics::Required,
|
||
report_families: true,
|
||
});
|
||
let handles = setup.handles();
|
||
|
||
let hashes = {
|
||
// SAFETY: as the H.264 leg — `setup` outlives this block and was created
|
||
// with the H.265 decode extensions + timeline/sync2 features.
|
||
let mut decoder = unsafe { VkH265Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||
.expect("wrap the device");
|
||
// The construction-time shape gate the client's ladder relies on, on the
|
||
// vector's own facts (Main, 4:2:0, 8-bit → NV12): a device that cannot
|
||
// host the combination refuses here with a caps reason instead of failing
|
||
// mid-stream.
|
||
decoder
|
||
.probe_stream_support(1, bit_depth_luma_minus8)
|
||
.unwrap_or_else(|e| {
|
||
panic!("{label}: the box must host this H.265 shape — {e:?}");
|
||
});
|
||
// SAFETY: as the H.264 leg — live instance/device, queue 0 of
|
||
// `graphics_qf` exists; destroyed at the end of this block.
|
||
let readback = unsafe {
|
||
Readback::new(
|
||
&setup.instance,
|
||
setup.pd,
|
||
&setup.device,
|
||
setup.graphics_qf,
|
||
display,
|
||
format,
|
||
)
|
||
};
|
||
let hashes = collect_hashes(&mut decoder, &readback, aus);
|
||
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing
|
||
// else references its handles.
|
||
unsafe { readback.destroy() };
|
||
hashes
|
||
};
|
||
|
||
// SAFETY: as the H.264 leg — decoder and readback are gone.
|
||
unsafe { setup.destroy() };
|
||
|
||
assert_bit_identical(&hashes, &goldens, label);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
|
||
fn h265_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
h265_parity_run(
|
||
&common::split_h265_aus(common::TEST_25FPS_H265),
|
||
GOLDENS_H265,
|
||
FRAME_COUNT,
|
||
0,
|
||
EXPECTED_FORMAT,
|
||
DISPLAY_H265,
|
||
"H.265",
|
||
);
|
||
}
|
||
|
||
/// The ten-bit path — the only leg in this file that is not eight-bit.
|
||
///
|
||
/// Every other golden set in this program is NV12, so no rung had pixel evidence
|
||
/// for its ten-bit path: the HDR legs proved a Main10 session BUILDS and streams
|
||
/// clean, which a stream decoding to garbage would also do. The goldens are P010
|
||
/// and the Vulkan pool is `G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16`, whose
|
||
/// samples are 16-bit words with the ten bits in the high end — the same layout,
|
||
/// which is why one golden file serves this rung and the D3D11VA one.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.265 Main 10 decode device (fleet boxes; see module docs)"]
|
||
fn main10_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
h265_parity_run(
|
||
&common::split_h265_aus(TEST_MAIN10_H265),
|
||
GOLDENS_MAIN10,
|
||
MAIN10_FRAME_COUNT,
|
||
2,
|
||
pf_vkdecode::P010,
|
||
(320, 240),
|
||
"HEVC Main 10",
|
||
);
|
||
}
|
||
|
||
/// The HEVC twin of [`low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec`]:
|
||
/// **our own host's HEVC**, in the shape the vendored vector cannot produce.
|
||
///
|
||
/// Its job is the opposite of the H.264 leg's. That one exists because the rung was
|
||
/// broken and only this stream shape could show it. This one exists because until now
|
||
/// no HEVC leg anywhere had decoded a single frame our own encoder produced: both
|
||
/// existing legs run vendored vectors, and the H.264 sibling is the standing proof
|
||
/// that a vector's silence about a stream shape is not evidence.
|
||
///
|
||
/// What it exercises that `h265_every_frame_hashes_bit_identical_to_libavcodec` does
|
||
/// not: a five-picture DPB with four references marked and no reordering, so the
|
||
/// `SlotMap` retires and reissues a slot on 115 of the 120 access units, back to back,
|
||
/// with the decode target taking the slot freed in the same access unit. The vector
|
||
/// reorders, which keeps that eviction slack and never puts the two together.
|
||
///
|
||
/// It is NOT the leg that would catch a moved `dpb_snapshot()` — see [`LOWDELAY_H265`]
|
||
/// for why that lands on the DXVA rung instead, and
|
||
/// [`the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable`]
|
||
/// for the guard that keeps the planner property itself pinned, on CPU, in ordinary CI.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
|
||
fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
h265_parity_run(
|
||
&common::split_h265_aus(LOWDELAY_H265),
|
||
GOLDENS_LOWDELAY_H265,
|
||
LOWDELAY_H265_FRAME_COUNT,
|
||
0,
|
||
EXPECTED_FORMAT,
|
||
DISPLAY_LOWDELAY_H265,
|
||
"H.265 (low-delay host stream)",
|
||
);
|
||
}
|
||
|
||
/// The HEVC leg of the production prefix form — the one that would have caught
|
||
/// the shipped defect. See [`h264_four_byte_start_codes_decode_bit_identically`].
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
|
||
fn h265_four_byte_start_codes_decode_bit_identically() {
|
||
let stream = common::h265_four_byte_start_codes(common::TEST_25FPS_H265);
|
||
h265_parity_run(
|
||
&common::split_h265_aus(&stream),
|
||
GOLDENS_H265,
|
||
FRAME_COUNT,
|
||
0,
|
||
EXPECTED_FORMAT,
|
||
DISPLAY_H265,
|
||
"H.265 (4-byte start codes)",
|
||
);
|
||
}
|
||
|
||
/// The AV1 twin of [`h265_parity_run`].
|
||
///
|
||
/// Concrete where the H.265 one is parameterised, because AV1 has exactly one
|
||
/// vendored vector and one shape (Main 4:2:0 8-bit, no film grain, 320x240); the
|
||
/// facts it hard-codes are re-derived from the planner, without a GPU, by
|
||
/// [`av1_goldens_and_the_ivf_split_agree_with_the_planner`], so a re-synced vector
|
||
/// of another shape fails in ordinary CI with the reason rather than on the fleet as
|
||
/// a confusing probe refusal. A second AV1 vector (Main 10, or one that uses
|
||
/// `show_existing_frame`) is the point at which this should grow the same parameters
|
||
/// the H.265 body carries — not before.
|
||
fn av1_parity_run(aus: &[&[u8]], label: &str) {
|
||
av1_parity_run_against(
|
||
aus,
|
||
label,
|
||
GOLDENS_AV1,
|
||
"data/test-25fps-av1.nv12.sha256",
|
||
FRAME_COUNT,
|
||
FRAME_COUNT,
|
||
DISPLAY_AV1,
|
||
);
|
||
}
|
||
|
||
/// [`av1_parity_run`] with its stream's own goldens and geometry, for the leg that
|
||
/// does not decode the vendored vector.
|
||
///
|
||
/// `units` and `frames` are SEPARATE parameters and must stay so. They are equal for
|
||
/// the low-delay host stream (one shown frame per temporal unit) and unequal for the
|
||
/// vendored vector only in the sense that its 250 units carry 274 coded frames of
|
||
/// which 250 are shown — deriving either from the other is exactly the assumption
|
||
/// AV1 punishes.
|
||
fn av1_parity_run_against(
|
||
aus: &[&[u8]],
|
||
label: &str,
|
||
goldens_file: &'static str,
|
||
goldens_path: &str,
|
||
units: usize,
|
||
frames: usize,
|
||
display: (u32, u32),
|
||
) {
|
||
// As the other legs: one codec at a time on the device, and the `set_var` below
|
||
// happens only under this lock (see `common::gpu_lock`).
|
||
let _gpu = common::gpu_lock();
|
||
|
||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||
|
||
let goldens = golden_hashes(goldens_file);
|
||
// Non-vacuity, before any hardware is touched: the right number of entries, all
|
||
// real digests, all distinct (see the helper's docs — a frozen-frame decoder
|
||
// must not be able to pass this leg).
|
||
assert_goldens_are_a_real_set(&goldens, frames, goldens_path);
|
||
// …and the leg must actually be fed something. An IVF whose packets failed to
|
||
// parse would hand `collect_hashes` an empty AU list, which delivers no frames
|
||
// and would then fail as a frame-count mismatch that reads like a decoder defect.
|
||
assert_eq!(
|
||
aus.len(),
|
||
units,
|
||
"{label}: the stream must split into {units} temporal units"
|
||
);
|
||
|
||
let setup = common::bring_up(&common::Request {
|
||
codec: common::AV1,
|
||
// As the other parity legs: the readback records on the graphics queue, so a
|
||
// device without a graphics family is skipped rather than defaulted.
|
||
graphics: common::Graphics::Required,
|
||
report_families: true,
|
||
});
|
||
let handles = setup.handles();
|
||
|
||
let hashes = {
|
||
// SAFETY: as the H.264/H.265 legs — `setup` outlives this block (destroyed
|
||
// below, after the decoder and readback drop at the block's end), it was
|
||
// created with the AV1 decode extension + timeline/sync2 features, and its
|
||
// queue fields name the families/queues it created.
|
||
let mut decoder = unsafe { VkAv1Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||
.expect("wrap the device");
|
||
// The construction-time shape gate, on the vector's own facts: 4:2:0, 8-bit,
|
||
// and NO film grain. The third argument is the load-bearing one — grain
|
||
// synthesis is part of the Vulkan decode PROFILE, so a box that offers only
|
||
// the grain-enabled profile (or only the disabled one) refuses HERE with a
|
||
// caps reason instead of failing at the first temporal unit.
|
||
decoder
|
||
.probe_stream_support(1, 8, false)
|
||
.unwrap_or_else(|e| {
|
||
panic!("{label}: the box must host AV1 Main 4:2:0 8-bit, no film grain — {e:?}");
|
||
});
|
||
// SAFETY: as the other legs — live instance/device, queue 0 of `graphics_qf`
|
||
// was created by the bring-up; destroyed at the end of this block.
|
||
let readback = unsafe {
|
||
Readback::new(
|
||
&setup.instance,
|
||
setup.pd,
|
||
&setup.device,
|
||
setup.graphics_qf,
|
||
display,
|
||
EXPECTED_FORMAT,
|
||
)
|
||
};
|
||
let hashes = collect_hashes(&mut decoder, &readback, aus);
|
||
// SAFETY: every readback was fence-waited inside `read_nv12`; nothing else
|
||
// references its handles.
|
||
unsafe { readback.destroy() };
|
||
hashes
|
||
};
|
||
|
||
// SAFETY: as the other legs — the decoder is gone (its Drop drained the queue and
|
||
// destroyed its session/pools) and the readback's handles are destroyed.
|
||
unsafe { setup.destroy() };
|
||
|
||
assert_bit_identical(&hashes, &goldens, label);
|
||
}
|
||
|
||
/// The AV1 rung's first pixel evidence.
|
||
///
|
||
/// 250 temporal units in, 250 DISPLAYED frames out (the 24 hidden frames the vector
|
||
/// also codes are decoded, referenced and never shown — module docs), each read back
|
||
/// as tightly packed NV12 over its `render_width` x `render_height` region and
|
||
/// compared against libavcodec's software decode.
|
||
///
|
||
/// What a failure looks like, and where to point it:
|
||
/// - **frame 0** — the sequence header or the per-frame parameter conversion:
|
||
/// `StdVideoAV1SequenceHeader`, the eight per-frame sub-blocks (tile info,
|
||
/// quantisation, segmentation, loop filter, CDEF, loop restoration, global motion,
|
||
/// film grain), the tile-group ranges, or the readback geometry. AV1 puts in the
|
||
/// frame header what H.26x puts in parameter sets, so a single wrong field here
|
||
/// damages every frame.
|
||
/// - **frame 1** — the first frame with a reference. Per-reference info, the
|
||
/// reference-NAME → DPB-slot table, or `ref_frame_idx` ordering.
|
||
/// - **later, then everything after** — DPB slot management, `refresh_frame_flags`,
|
||
/// or the hidden frames: a run that is clean until roughly the first multi-frame
|
||
/// temporal unit and wrong thereafter is the signature of the hidden ALTREF being
|
||
/// stored wrong or not at all.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||
fn av1_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
av1_parity_run(&common::split_av1_aus(common::TEST_25FPS_AV1), "AV1");
|
||
}
|
||
|
||
/// **Our own host's AV1, at the only resolution where it emits more than one tile.**
|
||
///
|
||
/// The leg above proves the conversion against a vector with `tile_cols = tile_rows
|
||
/// = 1` on every one of its 274 frames, so every tile-info field it exercises is the
|
||
/// degenerate case: one `width_in_sbs_minus_1`, one `height_in_sbs_minus_1`, one
|
||
/// `context_update_tile_id`, `TileCols = TileRows = 1`. This stream carries
|
||
/// `tile_rows = 2` with `height_in_sbs_minus_1 = [16, 16]` on all 60 frames, and both
|
||
/// tiles arrive in a single Tile Group OBU — so a conversion that got the tile arrays,
|
||
/// the per-tile sizing or the tile-group range wrong would decode the vector perfectly
|
||
/// and this stream visibly (see [`LOWDELAY_AV1`]).
|
||
///
|
||
/// It is also 4K, which no other parity leg in this program is: the readback moves
|
||
/// 12,441,600 bytes per frame instead of 115,200.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||
fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
|
||
av1_parity_run_against(
|
||
&common::split_av1_aus(LOWDELAY_AV1),
|
||
"AV1 (low-delay host stream, 4K two-tile)",
|
||
GOLDENS_LOWDELAY_AV1,
|
||
"data/lowdelay-3840x2160-av1.nv12.sha256",
|
||
LOWDELAY_AV1_UNIT_COUNT,
|
||
LOWDELAY_AV1_FRAME_COUNT,
|
||
DISPLAY_LOWDELAY_AV1,
|
||
);
|
||
}
|
||
|
||
/// Frame 0's pixels against libavcodec's, byte for byte — the diagnostic leg.
|
||
///
|
||
/// [`av1_every_frame_hashes_bit_identical_to_libavcodec`] is the verdict; this is
|
||
/// the microscope, and it decodes only as far as the first delivered frame. A hash
|
||
/// mismatch names no cause, and the four causes the parity leg's own message ranks
|
||
/// for a frame-0 divergence produce *completely different* pixel signatures:
|
||
///
|
||
/// | printed here | what it means |
|
||
/// |---|---|
|
||
/// | `luma IDENTICAL`, chroma differs | the chroma plane's layout — `PLANE_1`'s copy region, or a pool whose chroma plane starts somewhere other than where the readback reads it. NOT a decode problem |
|
||
/// | both differ, and a **shift** matches | readback geometry: the crop origin, or a copy extent taken from the pool rather than the render region. The printed `dy`/`dx` IS the error |
|
||
/// | both differ, deltas ≤ ~8 over most of the plane | an in-loop filter parameter — CDEF, loop restoration, the deblocking levels. Small and everywhere is what a filter does, and it is why the whole 250 frames go with it: CDEF runs before the frame is stored as a reference |
|
||
/// | both differ, deltas large and structured | quantisation, tile geometry, or the tile payloads themselves — the frame was reconstructed from the wrong data rather than filtered wrongly |
|
||
/// | ours is CONSTANT | nothing was decoded into the image the readback read |
|
||
///
|
||
/// It asserts equality last, so a failure prints the whole report above the panic.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||
fn av1_frame0_pixels_say_which_plane_and_how_badly() {
|
||
let _gpu = common::gpu_lock();
|
||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||
|
||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"the vector must split into 250 units"
|
||
);
|
||
let ours = av1_first_frame(&aus);
|
||
|
||
report_nv12_divergence(&ours, AV1_FRAME0, DISPLAY_AV1);
|
||
assert_eq!(
|
||
sha256_hex(&ours),
|
||
golden_hashes(GOLDENS_AV1)[0],
|
||
"AV1 frame 0 is not libavcodec's — read the report above for the class"
|
||
);
|
||
eprintln!("AV1 frame 0 is byte-identical to libavcodec");
|
||
}
|
||
|
||
/// `loop_filter_level[2]` and `[3]` — the U and V deblocking levels — as BIT
|
||
/// offsets from the start of the vector's FIRST access unit.
|
||
///
|
||
/// Derived rather than found: the IVF packet holds a temporal-delimiter OBU (2
|
||
/// bytes), a sequence-header OBU (2 + 11) and an `OBU_FRAME` header (1 + a 2-byte
|
||
/// leb128 size), so the uncompressed frame header starts at byte 18. Inside it
|
||
/// `loop_filter_level[0]` begins at bit 35 and the four levels are `f(6)` back to
|
||
/// back (5.9.11), which puts U at bit 47 and V at bit 53.
|
||
///
|
||
/// [`av1_frame0_probes_whether_the_driver_reads_the_chroma_deblocking_levels`]
|
||
/// re-parses the mutated unit before it decodes anything, so a re-synced vector
|
||
/// makes this fail loudly instead of poking an unrelated field.
|
||
const AV1_FRAME0_FILTER_LEVEL_U_BIT: usize = 18 * 8 + 47;
|
||
const AV1_FRAME0_FILTER_LEVEL_V_BIT: usize = 18 * 8 + 53;
|
||
|
||
/// The strongest deblocking level AV1 can code (`f(6)`), and the value the probe
|
||
/// rewrites both chroma levels to.
|
||
const MAX_LOOP_FILTER_LEVEL: u8 = 63;
|
||
|
||
/// The driver DOES read the chroma deblocking levels — and this is the test that
|
||
/// says so, after a pass of this program's history said the opposite.
|
||
///
|
||
/// ⚠⚠ **The claim "NVIDIA ignores `StdVideoAV1LoopFilter::loop_filter_level[2..3]`"
|
||
/// is refuted. Do not reintroduce it.** It was an honest reading of a real
|
||
/// measurement: the AV1 frame-0 parity leg came back `luma IDENTICAL, chroma
|
||
/// 319/38400 bytes differ, max |delta| 4`, software re-decode with both chroma
|
||
/// levels forced to zero reproduced that signature byte for byte, and this very
|
||
/// probe then came back IDENTICAL for `[8, 12]` and `[63, 63]`. Every step was
|
||
/// sound; the inference was not. The levels were reaching the driver intact — what
|
||
/// was NOT intact was the sequence header, whose `pColorConfig` block this crate
|
||
/// freed the instant `vkCreateVideoSessionParametersKHR` returned while the driver
|
||
/// went on dereferencing it at every decode. The recycled bytes read as
|
||
/// `mono_chrome = 1`, and a monochrome frame skips exactly `loop_filter_level[2..3]`
|
||
/// (AV1 7.14) — which is why rewriting them changed nothing, and why the
|
||
/// fingerprint was a perfect match for levels that were never applied.
|
||
/// `pf-vkdecode`'s `session_av1` module docs carry the capture and the fix.
|
||
///
|
||
/// So the probe survives its own refutation, with its verdict inverted: it now
|
||
/// PASSES, and it is the cheapest guard there is against that whole class coming
|
||
/// back. It decodes frame 0 twice — once from the vector as it sits, once from the
|
||
/// same unit with both chroma levels rewritten to the strongest AV1 can code — and
|
||
/// requires the pixels to differ. In software that rewrite moves 793 chroma bytes
|
||
/// with `max |delta| 29`, which no readback or crop error could hide, and it leaves
|
||
/// luma bit-identical, which is the control: a mutation that changed luma would
|
||
/// have desynchronised the header rather than changed the field.
|
||
///
|
||
/// If the two decodes are ever IDENTICAL again, the message below is the one to
|
||
/// act on — and the FIRST thing to check is not the driver but whether some block
|
||
/// the decode op points at is being freed before the op is recorded. That is what
|
||
/// it was last time, on a bug this test could not see.
|
||
#[test]
|
||
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
|
||
fn av1_frame0_probes_whether_the_driver_reads_the_chroma_deblocking_levels() {
|
||
let _gpu = common::gpu_lock();
|
||
std::env::set_var("PF_VKD_TEST_READBACK", "1");
|
||
|
||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||
assert_eq!(aus.len(), FRAME_COUNT);
|
||
|
||
let mutated_au = av1_frame0_with_max_chroma_deblocking(aus[0]);
|
||
let mut units: Vec<&[u8]> = aus.clone();
|
||
units[0] = &mutated_au;
|
||
|
||
let coded = av1_first_frame(&aus);
|
||
let maxed = av1_first_frame(&units);
|
||
|
||
let luma = (DISPLAY_AV1.0 * DISPLAY_AV1.1) as usize;
|
||
eprintln!(" coded chroma levels [8, 12] {}", sha256_hex(&coded));
|
||
eprintln!(" chroma levels [63, 63] {}", sha256_hex(&maxed));
|
||
eprintln!(" libavcodec's frame 0 {}", sha256_hex(AV1_FRAME0));
|
||
assert_eq!(
|
||
coded[..luma],
|
||
maxed[..luma],
|
||
"the chroma deblocking levels must not move a luma sample — if they did, \
|
||
the mutation desynchronised the frame header and the chroma comparison \
|
||
below means nothing"
|
||
);
|
||
assert_ne!(
|
||
coded[luma..],
|
||
maxed[luma..],
|
||
"the driver produced the SAME chroma from loop_filter_level[2..3] = [8, 12] \
|
||
and from [63, 63]. This happened once before and the driver was INNOCENT: \
|
||
a monochrome-looking sequence header makes it skip both levels, and ours \
|
||
looked monochrome because its `pColorConfig` block had been freed and \
|
||
reused before the decode op was recorded (see this test's docs). So audit \
|
||
the LIFETIME of everything the submission points at — the Std sequence \
|
||
header behind the parameters object first — before blaming the vendor"
|
||
);
|
||
eprintln!("the driver reads the chroma deblocking levels — the two decodes differ");
|
||
}
|
||
|
||
/// The vector's first access unit with both CHROMA deblocking levels rewritten to
|
||
/// [`MAX_LOOP_FILTER_LEVEL`] — and the proof, through the real parser, that this is
|
||
/// the only thing it changed.
|
||
///
|
||
/// The proof is not decoration. The offsets are derived from the spec's syntax
|
||
/// order rather than searched for, and a rewrite landing one field over would
|
||
/// desynchronise nothing (both neighbours are fixed-width) while silently probing
|
||
/// the wrong parameter. So every block the conversion reads is compared before and
|
||
/// after, and [`the_av1_chroma_deblocking_mutation_changes_only_those_two_levels`]
|
||
/// runs this on CPU in ordinary CI — the hardware run cannot be spent discovering
|
||
/// that the mutation was wrong.
|
||
fn av1_frame0_with_max_chroma_deblocking(au: &[u8]) -> Vec<u8> {
|
||
let mut mutated = au.to_vec();
|
||
for bit in [AV1_FRAME0_FILTER_LEVEL_U_BIT, AV1_FRAME0_FILTER_LEVEL_V_BIT] {
|
||
set_bits(&mut mutated, bit, 6, MAX_LOOP_FILTER_LEVEL);
|
||
}
|
||
|
||
let before = av1_first_header(au);
|
||
let after = av1_first_header(&mutated);
|
||
assert_eq!(
|
||
before.loop_filter_params.loop_filter_level,
|
||
[1, 7, 8, 12],
|
||
"the vendored vector's frame 0 codes these levels, and the whole probe is \
|
||
built around the last two of them"
|
||
);
|
||
assert_eq!(
|
||
after.loop_filter_params.loop_filter_level,
|
||
[1, 7, MAX_LOOP_FILTER_LEVEL, MAX_LOOP_FILTER_LEVEL],
|
||
"the rewrite must land on the two CHROMA levels and leave the luma pair \
|
||
alone — a luma change would make the probe's control meaningless"
|
||
);
|
||
// Everything else the conversion reads, unchanged: a rewrite that shifted the
|
||
// header would show up in one of these long before it showed up in pixels.
|
||
assert_eq!(
|
||
after.cdef_params, before.cdef_params,
|
||
"the CDEF block follows the loop filter block and is what a shifted rewrite \
|
||
would corrupt first"
|
||
);
|
||
assert_eq!(after.quantization_params, before.quantization_params);
|
||
assert_eq!(after.tile_info, before.tile_info);
|
||
assert_eq!(
|
||
after.loop_restoration_params,
|
||
before.loop_restoration_params
|
||
);
|
||
assert_eq!(after.segmentation_params, before.segmentation_params);
|
||
assert_eq!(
|
||
(
|
||
after.loop_filter_params.loop_filter_sharpness,
|
||
after.loop_filter_params.loop_filter_ref_deltas,
|
||
after.loop_filter_params.loop_filter_mode_deltas,
|
||
),
|
||
(
|
||
before.loop_filter_params.loop_filter_sharpness,
|
||
before.loop_filter_params.loop_filter_ref_deltas,
|
||
before.loop_filter_params.loop_filter_mode_deltas,
|
||
),
|
||
"the rest of the loop filter block rides after the levels and must survive"
|
||
);
|
||
assert_ne!(mutated, au, "the rewrite must actually change bytes");
|
||
mutated
|
||
}
|
||
|
||
/// [`av1_frame0_with_max_chroma_deblocking`] on CPU, so the GPU probe's mutation is
|
||
/// known-good before any device time is spent on it.
|
||
#[test]
|
||
fn the_av1_chroma_deblocking_mutation_changes_only_those_two_levels() {
|
||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||
let mutated = av1_frame0_with_max_chroma_deblocking(aus[0]);
|
||
// One byte may carry bits of both fields (U ends mid-byte), so the rewrite
|
||
// touches two or three bytes and no more — a whole-unit difference would mean
|
||
// `set_bits` walked off its field.
|
||
let changed = aus[0].iter().zip(&mutated).filter(|(a, b)| a != b).count();
|
||
assert!(
|
||
(1..=3).contains(&changed),
|
||
"twelve bits spanning at most three bytes, and {changed} bytes changed"
|
||
);
|
||
}
|
||
|
||
/// Overwrite the `bits`-wide big-endian bitfield at `bit` in `data`.
|
||
///
|
||
/// AV1's `f(n)` is MSB-first from the start of the OBU payload, which is what the
|
||
/// probe above needs to rewrite a syntax element in place: same width, same
|
||
/// position, so nothing after it shifts.
|
||
fn set_bits(data: &mut [u8], bit: usize, bits: usize, value: u8) {
|
||
for i in 0..bits {
|
||
let at = bit + i;
|
||
let mask = 1u8 << (7 - (at % 8));
|
||
let set = (value >> (bits - 1 - i)) & 1 == 1;
|
||
if set {
|
||
data[at / 8] |= mask;
|
||
} else {
|
||
data[at / 8] &= !mask;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The parsed frame header of the FIRST frame in one access unit.
|
||
fn av1_first_header(au: &[u8]) -> pf_bitstream::av1::ParsedFrameHeader {
|
||
let mut planner = pf_bitstream::av1::Av1Planner::new();
|
||
let plans = planner.plan_au(au).expect("the unit plans");
|
||
let plan = plans.first().expect("the unit carries a frame");
|
||
(*plan.header).clone()
|
||
}
|
||
|
||
/// Decode `aus` only as far as the FIRST delivered frame, and read it back as
|
||
/// tightly packed NV12 — the device half of both frame-0 legs.
|
||
///
|
||
/// Brings its own device up and tears it down, so one test may call it more than
|
||
/// once; the GPU lock and the readback hook are the caller's.
|
||
fn av1_first_frame(aus: &[&[u8]]) -> Vec<u8> {
|
||
let setup = common::bring_up(&common::Request {
|
||
codec: common::AV1,
|
||
graphics: common::Graphics::Required,
|
||
report_families: true,
|
||
});
|
||
let handles = setup.handles();
|
||
|
||
let ours = {
|
||
// SAFETY: as the parity legs — `setup` outlives this block and was created
|
||
// with the AV1 decode extension + timeline/sync2 features.
|
||
let mut decoder = unsafe { VkAv1Decoder::new(&handles, Box::new(NoopQueueLock)) }
|
||
.expect("wrap the device");
|
||
decoder
|
||
.probe_stream_support(1, 8, false)
|
||
.expect("the box must host AV1 Main 4:2:0 8-bit, no film grain");
|
||
// SAFETY: as the parity legs — live instance/device, queue 0 of `graphics_qf`.
|
||
let readback = unsafe {
|
||
Readback::new(
|
||
&setup.instance,
|
||
setup.pd,
|
||
&setup.device,
|
||
setup.graphics_qf,
|
||
DISPLAY_AV1,
|
||
EXPECTED_FORMAT,
|
||
)
|
||
};
|
||
|
||
// The FIRST delivered frame and no further: the first temporal unit is a
|
||
// key frame that shows, so this is one decode.
|
||
let mut first: Option<Vec<u8>> = None;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let frame = decoder
|
||
.decode(au)
|
||
.unwrap_or_else(|e| panic!("AU {index}: decode failed: {e}"));
|
||
if let Some(frame) = frame {
|
||
assert_eq!(
|
||
decoder.wait_status(&frame),
|
||
DecodeStatus::Ok,
|
||
"frame 0: decode op not COMPLETE\n state: {}",
|
||
decoder.debug_snapshot()
|
||
);
|
||
assert_eq!(frame.format, EXPECTED_FORMAT, "frame 0: pool format");
|
||
// SAFETY: the frame is delivered and unreleased on the readback's
|
||
// device, the pool carries TRANSFER_SRC, and the test is serialized.
|
||
first = Some(unsafe { readback.read_nv12(&frame) });
|
||
decoder
|
||
.release_frame(&frame, true)
|
||
.expect("frame 0: release");
|
||
// A temporal unit may carry more than one frame, and this leg stops
|
||
// at the first. Anything else the unit made ready is handed straight
|
||
// back — with `false`, because no presenter signalled its timeline
|
||
// (nothing read it) — rather than left held while the decoder drops.
|
||
while let Some(spare) = decoder.take_ready() {
|
||
decoder
|
||
.release_frame(&spare, false)
|
||
.expect("release an unread frame of the same temporal unit");
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
// SAFETY: every readback was fence-waited inside `read_nv12`.
|
||
unsafe { readback.destroy() };
|
||
first.expect("the vector's first temporal unit shows a frame")
|
||
};
|
||
|
||
// SAFETY: as the parity legs — the decoder and readback are gone.
|
||
unsafe { setup.destroy() };
|
||
ours
|
||
}
|
||
|
||
/// Per-plane statistics of `ours` against `want`, printed rather than asserted.
|
||
///
|
||
/// Everything here answers a question a hash cannot: WHICH plane, whether the
|
||
/// difference is a displacement or a value error, and how big. See
|
||
/// [`av1_frame0_pixels_say_which_plane_and_how_badly`] for how to read it.
|
||
fn report_nv12_divergence(ours: &[u8], want: &[u8], display: (u32, u32)) {
|
||
let (width, height) = (display.0 as usize, display.1 as usize);
|
||
let luma = width * height;
|
||
assert_eq!(ours.len(), want.len(), "both frames are the same layout");
|
||
assert_eq!(ours.len(), luma * 3 / 2, "tightly packed NV12");
|
||
|
||
eprintln!(
|
||
"--- AV1 frame 0: {width}x{height} NV12, {} bytes ---",
|
||
ours.len()
|
||
);
|
||
eprintln!(" ours {}", sha256_hex(ours));
|
||
eprintln!(" golden {}", sha256_hex(want));
|
||
|
||
// A plane that never varies means nothing was decoded into the image at all,
|
||
// which is a different failure from decoding it wrongly.
|
||
let flat = |plane: &[u8]| plane.iter().all(|b| *b == plane[0]);
|
||
if flat(&ours[..luma]) {
|
||
eprintln!(
|
||
" ⚠ our LUMA is constant ({}) — nothing decoded here",
|
||
ours[0]
|
||
);
|
||
}
|
||
if flat(&ours[luma..]) {
|
||
eprintln!(
|
||
" ⚠ our CHROMA is constant ({}) — nothing decoded here",
|
||
ours[luma]
|
||
);
|
||
}
|
||
|
||
for (name, ours, want) in [
|
||
("luma ", &ours[..luma], &want[..luma]),
|
||
("chroma", &ours[luma..], &want[luma..]),
|
||
] {
|
||
if ours == want {
|
||
eprintln!(" {name}: IDENTICAL ({} bytes)", ours.len());
|
||
continue;
|
||
}
|
||
let mut differing = 0usize;
|
||
let mut max_delta = 0u32;
|
||
let mut total_delta = 0u64;
|
||
// |delta| buckets: 1, 2, 3-4, 5-8, 9-16, 17-64, 65+.
|
||
let mut buckets = [0usize; 7];
|
||
let mut first: Vec<(usize, u8, u8)> = Vec::new();
|
||
for (i, (a, b)) in ours.iter().zip(want.iter()).enumerate() {
|
||
if a == b {
|
||
continue;
|
||
}
|
||
let delta = u32::from(a.abs_diff(*b));
|
||
differing += 1;
|
||
max_delta = max_delta.max(delta);
|
||
total_delta += u64::from(delta);
|
||
let bucket = match delta {
|
||
1 => 0,
|
||
2 => 1,
|
||
3..=4 => 2,
|
||
5..=8 => 3,
|
||
9..=16 => 4,
|
||
17..=64 => 5,
|
||
_ => 6,
|
||
};
|
||
buckets[bucket] += 1;
|
||
if first.len() < 8 {
|
||
first.push((i, *a, *b));
|
||
}
|
||
}
|
||
let percent = 100.0 * differing as f64 / ours.len() as f64;
|
||
eprintln!(
|
||
" {name}: {differing}/{} bytes differ ({percent:.2}%), max |delta| {max_delta}, \
|
||
mean |delta| over the differing bytes {:.2}",
|
||
ours.len(),
|
||
total_delta as f64 / differing as f64
|
||
);
|
||
eprintln!(
|
||
" |delta| histogram 1:{} 2:{} 3-4:{} 5-8:{} 9-16:{} 17-64:{} 65+:{}",
|
||
buckets[0], buckets[1], buckets[2], buckets[3], buckets[4], buckets[5], buckets[6]
|
||
);
|
||
// One ROW is `width` bytes in both planes — luma because it is `width`
|
||
// samples wide, interleaved chroma because it is `width / 2` samples wide
|
||
// and two bytes per sample. So one formula serves both, and the chroma
|
||
// coordinates it prints are in chroma units.
|
||
let positions: Vec<String> = first
|
||
.iter()
|
||
.map(|(i, a, b)| format!("(x{},y{}) {a}≠{b}", i % width, i / width))
|
||
.collect();
|
||
eprintln!(" first differing: {}", positions.join(" "));
|
||
}
|
||
|
||
// A displacement, not a difference: does our luma equal the reference read a
|
||
// few rows or columns over? That is what a wrong crop origin or a copy extent
|
||
// taken from the pool rather than the render region looks like, and the shift
|
||
// that matches IS the error.
|
||
if ours[..luma] != want[..luma] {
|
||
let mut best: Option<(i32, i32, f64)> = None;
|
||
for dy in -4i32..=4 {
|
||
for dx in -8i32..=8 {
|
||
if (dy, dx) == (0, 0) {
|
||
continue;
|
||
}
|
||
let (mut hit, mut seen) = (0usize, 0usize);
|
||
for y in 8..height - 8 {
|
||
for x in 8..width - 8 {
|
||
let sy = (y as i32 + dy) as usize;
|
||
let sx = (x as i32 + dx) as usize;
|
||
seen += 1;
|
||
if ours[y * width + x] == want[sy * width + sx] {
|
||
hit += 1;
|
||
}
|
||
}
|
||
}
|
||
let score = hit as f64 / seen as f64;
|
||
if best.is_none_or(|(_, _, b)| score > b) {
|
||
best = Some((dy, dx, score));
|
||
}
|
||
}
|
||
}
|
||
// The identity's own score, for scale: a decode that is merely slightly
|
||
// wrong still matches most bytes in place, so a shift only means something
|
||
// when it beats staying put.
|
||
let (mut hit, mut seen) = (0usize, 0usize);
|
||
for y in 8..height - 8 {
|
||
for x in 8..width - 8 {
|
||
seen += 1;
|
||
if ours[y * width + x] == want[y * width + x] {
|
||
hit += 1;
|
||
}
|
||
}
|
||
}
|
||
let identity = hit as f64 / seen as f64;
|
||
if let Some((dy, dx, score)) = best {
|
||
eprintln!(
|
||
" luma shift probe: in place {:.3} · best shift dy{dy:+} dx{dx:+} {score:.3}{}",
|
||
identity,
|
||
if score > identity + 0.05 {
|
||
" ⚠ A SHIFT FITS BETTER — this is readback geometry, not decode"
|
||
} else {
|
||
" (no shift fits better: the pixels are in the right place and \
|
||
carry the wrong values)"
|
||
}
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// CPU coherence guards — NOT `#[ignore]`d.
|
||
//
|
||
// The legs above only run on the fleet, so without these nothing in ordinary CI
|
||
// notices that a re-synced vendored vector, a golden regeneration or an edit to
|
||
// `common`'s AU splitters has made the two disagree. They would then fail on the
|
||
// fleet as a frame-count mismatch, which reads like a decoder defect and costs a
|
||
// hardware round trip to disprove.
|
||
//
|
||
// Each guard pins the whole chain the parity verdict rests on: the AU split, the
|
||
// planner's output count, the vector's shape and the golden set — with NO GPU
|
||
// involved. And they are what make the verdicts non-vacuous: a comparison of zero
|
||
// frames, or of 250 copies of one digest, would otherwise "pass" on any hardware
|
||
// (see [`assert_goldens_are_a_real_set`]).
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn h265_goldens_and_au_split_agree_with_the_planner() {
|
||
use pf_bitstream::h265::H265Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_H265);
|
||
assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps-h265.nv12.sha256");
|
||
|
||
// The AU split the parity leg feeds the decoder. `common::split_h265_aus` is
|
||
// the copy of pf-bitstream's private splitter, and it keys on HEVC's 2-byte
|
||
// NAL header — a `+ 1` there (H.264's offset) silently merges or splits AUs.
|
||
let aus = common::split_h265_aus(common::TEST_25FPS_H265);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"the vendored H.265 vector is {FRAME_COUNT} access units \
|
||
(pf-bitstream's own planner test pins the same number)"
|
||
);
|
||
|
||
// Walk the CPU planner over the same AUs: it is the authority on how many
|
||
// frames the GPU leg can possibly deliver, because the decoder builds exactly
|
||
// one delivered frame per `dpb.outputs` id (plus the flush tail).
|
||
let mut planner = H265Planner::new();
|
||
let mut outputs = 0usize;
|
||
let mut iraps = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!(
|
||
"AU {index}: the clean vector must plan without errors, got {e:?} \
|
||
— if this is RaslSkipped the vector has gained CRA/RASL pictures \
|
||
and the parity legs' expected frame count needs rederiving"
|
||
);
|
||
});
|
||
outputs += plan.dpb.outputs.len();
|
||
iraps += usize::from(plan.picture.is_irap);
|
||
// Pin the picture shape the H.265 legs hard-code. They call
|
||
// `probe_stream_support(1, 0)` (4:2:0, 8-bit) and assert the NV12 output
|
||
// format; a re-synced Main-10 or 4:4:4 vector would make both of those
|
||
// silently probe and expect the WRONG profile on the fleet, which is a
|
||
// confusing hardware-only failure. Fail here, on CPU, with the reason.
|
||
assert_eq!(
|
||
(
|
||
plan.picture.chroma_format_idc,
|
||
plan.picture.bit_depth_luma_minus8
|
||
),
|
||
(1, 0),
|
||
"AU {index}: the vendored H.265 vector must stay Main 4:2:0 8-bit — \
|
||
the parity and smoke legs hard-code probe_stream_support(1, 0) and \
|
||
an NV12 output format, so a re-synced vector of another shape needs \
|
||
both legs updated, not just the goldens"
|
||
);
|
||
if index == 0 {
|
||
assert!(plan.picture.is_idr, "the vector opens with an IDR");
|
||
assert_eq!(
|
||
(plan.picture.coded_width, plan.picture.coded_height),
|
||
DISPLAY_H265,
|
||
"the vector is 320x240"
|
||
);
|
||
assert_eq!(
|
||
(
|
||
plan.picture.display_crop.x,
|
||
plan.picture.display_crop.y,
|
||
plan.picture.display_crop.width,
|
||
plan.picture.display_crop.height,
|
||
),
|
||
(0, 0, DISPLAY_H265.0, DISPLAY_H265.1),
|
||
"the vector carries NO conformance window — coded size IS display \
|
||
size (the golden header's claim, and what `Readback` asserts)"
|
||
);
|
||
}
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {} hashes — \
|
||
the parity leg's frame-count assertion would fail on hardware for a \
|
||
reason that has nothing to do with the GPU",
|
||
goldens.len()
|
||
);
|
||
// No CRA/BLA anywhere means `PlanError::RaslSkipped` — the Ok-skip that
|
||
// returns `Ok(None)` rather than an error (h265 module docs, and
|
||
// `VkH265Decoder::decode`'s RASL arm) — is UNREACHABLE on this vector, so the
|
||
// count above cannot be perturbed by it. If a re-synced vector ever opens with
|
||
// a CRA, this assertion fires first and says where to look.
|
||
assert_eq!(
|
||
iraps, 1,
|
||
"the vector holds exactly one IRAP (the opening IDR); a CRA/BLA would make \
|
||
RASL skips reachable and the expected frame count needs rederiving"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn h264_goldens_and_au_split_agree_with_the_planner() {
|
||
use pf_bitstream::h264::H264Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_H264);
|
||
assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps.nv12.sha256");
|
||
|
||
let aus = common::split_h264_aus(common::TEST_25FPS_H264);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"the vendored H.264 vector is {FRAME_COUNT} access units"
|
||
);
|
||
|
||
let mut planner = H264Planner::new();
|
||
let mut outputs = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner
|
||
.plan_au(au)
|
||
.unwrap_or_else(|e| panic!("AU {index}: the clean vector must plan, got {e:?}"));
|
||
outputs += plan.dpb.outputs.len();
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {} hashes",
|
||
goldens.len()
|
||
);
|
||
}
|
||
|
||
/// The low-delay stream's own CPU guard, plus the property that makes it worth
|
||
/// vendoring at all.
|
||
///
|
||
/// The goldens/AU/output agreement is the same three-way check
|
||
/// [`h264_goldens_and_au_split_agree_with_the_planner`] does. What is extra here is
|
||
/// the last assertion: this stream must actually REACH the aliasing precondition —
|
||
/// a picture removed by the same access unit whose `dpb_refs` still names it — on
|
||
/// nearly every access unit. If a re-generation ever produced a stream that did not,
|
||
/// the GPU leg above would still pass 120/120 while proving nothing the vendored
|
||
/// vector does not already prove, and nothing else would say so.
|
||
#[test]
|
||
fn the_low_delay_stream_agrees_with_its_goldens_and_still_exercises_the_aliasing_shape() {
|
||
use pf_bitstream::h264::H264Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_LOWDELAY);
|
||
assert_goldens_are_a_real_set(
|
||
&goldens,
|
||
LOWDELAY_FRAME_COUNT,
|
||
"data/lowdelay-640x480.nv12.sha256",
|
||
);
|
||
|
||
let aus = common::split_h264_aus(LOWDELAY_H264);
|
||
assert_eq!(aus.len(), LOWDELAY_FRAME_COUNT);
|
||
|
||
let mut planner = H264Planner::new();
|
||
let mut outputs = 0usize;
|
||
let mut both = 0usize;
|
||
let mut first_sps = None;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner
|
||
.plan_au(au)
|
||
.unwrap_or_else(|e| panic!("AU {index}: the low-delay stream must plan, got {e:?}"));
|
||
outputs += plan.dpb.outputs.len();
|
||
both += plan
|
||
.dpb
|
||
.removed
|
||
.iter()
|
||
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
|
||
.count();
|
||
first_sps.get_or_insert((
|
||
plan.sps.max_num_ref_frames,
|
||
plan.picture.max_dpb_frames,
|
||
plan.sps.vui_parameters.max_num_reorder_frames,
|
||
));
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {} hashes",
|
||
goldens.len()
|
||
);
|
||
|
||
// The three SPS facts that make the shape reachable, pinned so a regenerated
|
||
// stream from a different encoder cannot quietly stop being low-delay.
|
||
assert_eq!(
|
||
first_sps,
|
||
Some((3, 3, 0)),
|
||
"max_num_ref_frames, DPB depth and max_num_reorder_frames — a DPB exactly as \
|
||
deep as the reference count, with no reordering, is what puts the unmarking \
|
||
and the eviction in one access unit"
|
||
);
|
||
assert_eq!(
|
||
both, 117,
|
||
"the stream must still remove pictures its own reference lists name — that is \
|
||
the ONLY reason it is vendored, and without it the GPU leg is a duplicate of \
|
||
the conformance one"
|
||
);
|
||
}
|
||
|
||
/// The HEVC low-delay stream's CPU guard — the twin of the H.264 one above, with the
|
||
/// extra assertion HEVC needs and H.264 does not.
|
||
///
|
||
/// H.264's guard pins that the stream still ALIASES (117 of 120), because its GPU leg
|
||
/// exists to catch a defect. HEVC's leg exists to keep an exemption from rotting, so
|
||
/// pinning `both == 0` alone would be exactly the vacuous check `fd6241a2` called out:
|
||
/// zero is also what a stream that never removes anything reports, and what a stream
|
||
/// that reorders reports. So this pins three numbers instead:
|
||
///
|
||
/// - **115 access units remove a picture** — the stream reaches the DPB pressure at all;
|
||
/// - **0 of them intersect `dpb_refs`** — the exemption, measured;
|
||
/// - **115 of them WOULD intersect** a snapshot taken before `decode_rps`.
|
||
///
|
||
/// The third is what makes the second worth having. `pre_rps_marked(N)` is exact rather
|
||
/// than approximate: `begin_picture` runs `decode_rps` → `update_dpb_before_decoding` →
|
||
/// `dpb_snapshot`, and the only thing that happens between AU N-1's snapshot and AU N's
|
||
/// `decode_rps` is `finish_picture(N-1)` storing its picture marked short-term. So the
|
||
/// marked set AU N's RPS sees is exactly `dpb_refs(N-1) ∪ {stored(N-1)}`, which is what
|
||
/// `dpb_snapshot()` would have returned from the other side of that call.
|
||
#[test]
|
||
fn the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable() {
|
||
use pf_bitstream::h265::H265Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_LOWDELAY_H265);
|
||
assert_goldens_are_a_real_set(
|
||
&goldens,
|
||
LOWDELAY_H265_FRAME_COUNT,
|
||
"data/lowdelay-640x480-h265.nv12.sha256",
|
||
);
|
||
|
||
let aus = common::split_h265_aus(LOWDELAY_H265);
|
||
assert_eq!(aus.len(), LOWDELAY_H265_FRAME_COUNT);
|
||
|
||
let mut planner = H265Planner::new();
|
||
let mut outputs = 0usize;
|
||
let mut iraps = 0usize;
|
||
let mut with_removals = 0usize;
|
||
let mut both = 0usize;
|
||
let mut would_alias = 0usize;
|
||
let mut first_sps = None;
|
||
// The marked DPB as AU N's `decode_rps` finds it: AU N-1's snapshot plus the
|
||
// picture AU N-1 stored. See the doc comment for why this is exact.
|
||
let mut pre_rps_marked: Vec<u64> = Vec::new();
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("AU {index}: the low-delay HEVC stream must plan, got {e:?}")
|
||
});
|
||
outputs += plan.dpb.outputs.len();
|
||
iraps += usize::from(plan.picture.is_irap);
|
||
if !plan.dpb.removed.is_empty() {
|
||
with_removals += 1;
|
||
}
|
||
both += plan
|
||
.dpb
|
||
.removed
|
||
.iter()
|
||
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
|
||
.count();
|
||
would_alias += plan
|
||
.dpb
|
||
.removed
|
||
.iter()
|
||
.filter(|id| pre_rps_marked.contains(id))
|
||
.count();
|
||
|
||
// The picture shape both HEVC legs hard-code: `probe_stream_support(1, 0)`
|
||
// and an NV12 pool. Fail here, on CPU, rather than as a confusing
|
||
// hardware-only refusal.
|
||
assert_eq!(
|
||
(
|
||
plan.picture.chroma_format_idc,
|
||
plan.picture.bit_depth_luma_minus8
|
||
),
|
||
(1, 0),
|
||
"AU {index}: the low-delay HEVC stream must stay Main 4:2:0 8-bit"
|
||
);
|
||
if index == 0 {
|
||
assert!(plan.picture.is_idr, "the stream opens with an IDR");
|
||
assert_eq!(
|
||
(plan.picture.coded_width, plan.picture.coded_height),
|
||
DISPLAY_LOWDELAY_H265,
|
||
"the stream is 640x480"
|
||
);
|
||
assert_eq!(
|
||
(
|
||
plan.picture.display_crop.x,
|
||
plan.picture.display_crop.y,
|
||
plan.picture.display_crop.width,
|
||
plan.picture.display_crop.height,
|
||
),
|
||
(0, 0, DISPLAY_LOWDELAY_H265.0, DISPLAY_LOWDELAY_H265.1),
|
||
"640 and 480 are both multiples of MinCbSizeY, so there is no \
|
||
conformance window and the coded size IS what the goldens hashed"
|
||
);
|
||
}
|
||
first_sps.get_or_insert((
|
||
plan.picture.max_dpb_frames,
|
||
plan.sps.max_num_reorder_pics[usize::from(plan.sps.max_sub_layers_minus1)],
|
||
));
|
||
|
||
pre_rps_marked = plan.dpb_refs.iter().map(|r| r.id).collect();
|
||
if let Some(id) = plan.dpb.stored {
|
||
assert!(
|
||
plan.picture.is_reference,
|
||
"AU {index}: every picture of this stream is a reference — a \
|
||
sub-layer non-reference picture would break the pre-RPS \
|
||
reconstruction below"
|
||
);
|
||
pre_rps_marked.push(id);
|
||
}
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {} hashes",
|
||
goldens.len()
|
||
);
|
||
assert_eq!(
|
||
iraps, 1,
|
||
"the stream holds exactly one IRAP (the opening IDR); a CRA/BLA would make \
|
||
RASL skips reachable and the expected frame count needs rederiving"
|
||
);
|
||
assert_eq!(
|
||
first_sps,
|
||
Some((5, 0)),
|
||
"DPB depth and sps_max_num_reorder_pics — a five-picture DPB against the four \
|
||
pictures 8.3.2 keeps marked, with no reordering, is what puts an RPS drop and \
|
||
the eviction it causes in one access unit"
|
||
);
|
||
|
||
assert_eq!(
|
||
with_removals, 115,
|
||
"the stream must still retire a picture on nearly every access unit; without \
|
||
that the two numbers below are both trivially zero"
|
||
);
|
||
assert_eq!(
|
||
both, 0,
|
||
"{both} picture(s) are in an access unit's own marked DPB AND removed by it. \
|
||
That is the H.264/AV1 aliasing precondition, and HEVC is supposed to be \
|
||
structurally incapable of it — `H265Planner`'s snapshot has moved ahead of \
|
||
`decode_rps`. Restore the ordering, or give the HEVC conversions the \
|
||
`release_after_decode` deferral the other two carry; do NOT relax this number"
|
||
);
|
||
assert_eq!(
|
||
would_alias, 115,
|
||
"the fixture must stay CAPABLE of exposing the defect it is here to rule out. \
|
||
A regenerated stream that reordered, or that carried a DPB deeper than its \
|
||
reference count, would report 0 here — and the zero above would then prove \
|
||
nothing at all, exactly as `test-25fps.h264` proved nothing for two milestones"
|
||
);
|
||
}
|
||
|
||
/// The AV1 low-delay stream's CPU guard, and the property it was vendored for: **more
|
||
/// than one tile**.
|
||
///
|
||
/// A regenerated fixture could lose that in two silent ways — a re-run at a lower
|
||
/// resolution (1440p and below are single-tile on this encoder) or a driver/encoder
|
||
/// change that stopped splitting — and in both cases the GPU leg would go on passing
|
||
/// 60/60 while duplicating what the vendored vector already covers. So the tile shape
|
||
/// is asserted per frame, not sampled.
|
||
///
|
||
/// It also pins AV1's frame accounting explicitly rather than by derivation. The
|
||
/// vendored vector is 250 units / 274 coded / 24 hidden / 250 shown; this stream is
|
||
/// 60 / 60 / 0 / 60. Neither is the general case, and a leg that assumed either would
|
||
/// break on the other for reasons that look like a decoder defect.
|
||
#[test]
|
||
fn the_low_delay_av1_stream_agrees_with_its_goldens_and_still_carries_two_tiles() {
|
||
use pf_bitstream::av1::Av1Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_LOWDELAY_AV1);
|
||
assert_goldens_are_a_real_set(
|
||
&goldens,
|
||
LOWDELAY_AV1_FRAME_COUNT,
|
||
"data/lowdelay-3840x2160-av1.nv12.sha256",
|
||
);
|
||
|
||
let aus = common::split_av1_aus(LOWDELAY_AV1);
|
||
assert_eq!(
|
||
aus.len(),
|
||
LOWDELAY_AV1_UNIT_COUNT,
|
||
"the low-delay AV1 stream is {LOWDELAY_AV1_UNIT_COUNT} temporal units"
|
||
);
|
||
assert!(
|
||
aus.iter().all(|au| !au.is_empty()),
|
||
"no temporal unit is empty — an IVF reader returning empty packets would make \
|
||
the parity leg decode nothing and blame the decoder"
|
||
);
|
||
|
||
let mut planner = Av1Planner::new();
|
||
let mut outputs = 0usize;
|
||
let mut coded_frames = 0usize;
|
||
let mut multi_frame_units = 0usize;
|
||
let mut hidden = 0usize;
|
||
let mut show_existing = 0usize;
|
||
let mut keys = 0usize;
|
||
let mut with_removals = 0usize;
|
||
let mut aliasing_shape = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plans = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("temporal unit {index}: the low-delay stream must plan, got {e:?}")
|
||
});
|
||
if plans.len() > 1 {
|
||
multi_frame_units += 1;
|
||
}
|
||
for plan in &plans {
|
||
coded_frames += 1;
|
||
outputs += plan.dpb.outputs.len();
|
||
keys += usize::from(plan.picture.is_key);
|
||
hidden += usize::from(!plan.picture.show_frame);
|
||
if plan.dpb.stored.is_none() {
|
||
show_existing += 1;
|
||
}
|
||
assert!(
|
||
plan.warnings.is_empty(),
|
||
"temporal unit {index}: a clean stream plans without warnings, got {:?}",
|
||
plan.warnings
|
||
);
|
||
|
||
// THE PROPERTY. Two tile ROWS, one tile COLUMN, both tiles in a single
|
||
// Tile Group OBU — the 4K split-encode shape, on every frame including
|
||
// the key frame.
|
||
let tile = &plan.header.tile_info;
|
||
assert_eq!(
|
||
(tile.tile_cols, tile.tile_rows),
|
||
(1, 2),
|
||
"frame {coded_frames} (unit {index}): this fixture exists because our \
|
||
encoder emits TWO TILE ROWS at 4K. A single-tile stream here means it \
|
||
was regenerated at a lower resolution (1440p and below measured \
|
||
single-tile) or the encoder stopped splitting — either way the GPU leg \
|
||
below is now a duplicate of the vendored vector's and this fixture's \
|
||
260 KB buys nothing. Regenerate at 3840x2160; do NOT relax this"
|
||
);
|
||
assert_eq!(
|
||
(
|
||
tile.width_in_sbs_minus_1[0],
|
||
tile.height_in_sbs_minus_1[0],
|
||
tile.height_in_sbs_minus_1[1],
|
||
),
|
||
(59, 16, 16),
|
||
"frame {coded_frames}: the per-tile superblock sizing the conversions \
|
||
copy into their tile arrays"
|
||
);
|
||
assert_eq!(
|
||
plan.tiles.len(),
|
||
1,
|
||
"frame {coded_frames}: both tiles ride in ONE Tile Group OBU"
|
||
);
|
||
assert_eq!(
|
||
(plan.tiles[0].tg_start, plan.tiles[0].tg_end),
|
||
(0, 1),
|
||
"frame {coded_frames}: the single tile group covers tiles 0..=1 — a \
|
||
range of 0..=0 is the truncation shape the host once shipped"
|
||
);
|
||
|
||
// The picture shape both AV1 legs hard-code (`probe_stream_support(1, 8,
|
||
// false)` plus an NV12 pool). Film grain especially: it is part of the
|
||
// Vulkan decode PROFILE, so a grain-bearing stream is a different device
|
||
// requirement, not merely different pixels.
|
||
assert_eq!(
|
||
(
|
||
plan.picture.chroma_format_idc,
|
||
plan.picture.bit_depth,
|
||
plan.sequence.film_grain_params_present,
|
||
),
|
||
(1, 8, false),
|
||
"frame {coded_frames}: Main 4:2:0 8-bit, no film grain"
|
||
);
|
||
if coded_frames == 1 {
|
||
assert!(plan.picture.is_key, "the stream opens on a key frame");
|
||
assert_eq!(
|
||
(plan.picture.render_width, plan.picture.render_height),
|
||
DISPLAY_LOWDELAY_AV1,
|
||
"the render region the readback crops to and the goldens hash"
|
||
);
|
||
assert_eq!(
|
||
(plan.picture.upscaled_width, plan.picture.frame_height),
|
||
DISPLAY_LOWDELAY_AV1,
|
||
"no superres and no AV1 conformance-window equivalent — the coded \
|
||
picture IS the render region"
|
||
);
|
||
}
|
||
|
||
if !plan.dpb.removed.is_empty() {
|
||
with_removals += 1;
|
||
}
|
||
aliasing_shape += plan
|
||
.dpb
|
||
.removed
|
||
.iter()
|
||
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
|
||
.count();
|
||
}
|
||
}
|
||
|
||
// AV1's frame accounting, pinned rather than derived. This stream is the SIMPLE
|
||
// shape — one shown frame per temporal unit — which is exactly why it must be
|
||
// stated: the vendored vector is not, and a leg that learned its habits from one
|
||
// of them silently mis-counts the other.
|
||
assert_eq!(
|
||
(
|
||
coded_frames,
|
||
outputs,
|
||
multi_frame_units,
|
||
hidden,
|
||
show_existing,
|
||
keys
|
||
),
|
||
(
|
||
LOWDELAY_AV1_FRAME_COUNT,
|
||
LOWDELAY_AV1_FRAME_COUNT,
|
||
0,
|
||
0,
|
||
0,
|
||
1
|
||
),
|
||
"coded / displayed / multi-frame units / hidden / show_existing / key frames — \
|
||
our host emits one shown frame per temporal unit and one key frame at the \
|
||
head, against the vendored vector's 274 / 250 / 24 / 24 / 0 / 1"
|
||
);
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {}",
|
||
goldens.len()
|
||
);
|
||
|
||
// Not the reason this fixture exists — the vendored vector already aliases on 268
|
||
// of its 274 frames — but recorded so a regeneration cannot quietly drop below the
|
||
// vector's coverage while claiming to be the host-shaped stream.
|
||
assert_eq!(
|
||
(with_removals, aliasing_shape),
|
||
(55, 55),
|
||
"55 of the 60 frames displace a reference they still name, which is the \
|
||
precondition `release_after_decode` exists for"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_main10_vector_is_ten_bit_and_agrees_with_its_goldens() {
|
||
use pf_bitstream::h265::H265Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_MAIN10);
|
||
assert_goldens_are_a_real_set(&goldens, MAIN10_FRAME_COUNT, "data/test-main10.p010.sha256");
|
||
|
||
let aus = common::split_h265_aus(TEST_MAIN10_H265);
|
||
assert_eq!(
|
||
aus.len(),
|
||
MAIN10_FRAME_COUNT,
|
||
"the Main 10 vector is {MAIN10_FRAME_COUNT} access units"
|
||
);
|
||
|
||
let mut planner = H265Planner::new();
|
||
let mut outputs = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("AU {index}: the Main 10 vector must plan without errors, got {e:?}")
|
||
});
|
||
// The whole reason this vector exists. Every other golden set in this
|
||
// program is eight-bit; a regenerated vector that came out eight-bit would
|
||
// turn the ten-bit parity leg into a second run of the eight-bit path, and
|
||
// it would PASS, because its goldens would have been regenerated with it.
|
||
assert_eq!(
|
||
(
|
||
plan.picture.chroma_format_idc,
|
||
plan.picture.bit_depth_luma_minus8,
|
||
plan.picture.bit_depth_chroma_minus8,
|
||
),
|
||
(1, 2, 2),
|
||
"AU {index}: the Main 10 vector must stay 4:2:0 at ten bits"
|
||
);
|
||
if index == 0 {
|
||
assert!(plan.picture.is_idr, "the vector opens with an IDR");
|
||
assert_eq!(
|
||
(plan.picture.coded_width, plan.picture.coded_height),
|
||
(320, 240),
|
||
"the goldens hash a 320x240 picture"
|
||
);
|
||
}
|
||
outputs += plan.dpb.outputs.len();
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {}",
|
||
goldens.len()
|
||
);
|
||
}
|
||
|
||
/// The AV1 leg's whole chain, with no GPU: the golden set, the IVF split, the shape
|
||
/// the leg hard-codes, and — the one that matters — that **250 goldens is the
|
||
/// DISPLAY count of a 274-frame vector**, re-derived from the planner rather than
|
||
/// asserted from a comment.
|
||
///
|
||
/// Every number here is a way the fleet run could otherwise fail for a reason that
|
||
/// is not the decoder:
|
||
///
|
||
/// - a golden file regenerated per CODED frame would carry 274 hashes and the leg
|
||
/// would report a frame-count mismatch that reads exactly like dropped frames;
|
||
/// - an IVF reader that lost packets would feed a short AU list and the leg would
|
||
/// report the same thing;
|
||
/// - a re-synced vector at another bit depth, sampling, or with film grain would
|
||
/// make `probe_stream_support(1, 8, false)` probe the WRONG Vulkan profile and the
|
||
/// readback expect the wrong format, which on hardware surfaces as a caps refusal
|
||
/// or half a hashed picture;
|
||
/// - and if the 24 hidden frames ever disappeared, the leg would still pass while
|
||
/// having quietly stopped exercising multi-frame temporal units at all — the one
|
||
/// thing AV1 has that neither H.26x vector does.
|
||
#[test]
|
||
fn av1_goldens_and_the_ivf_split_agree_with_the_planner() {
|
||
use pf_bitstream::av1::Av1Planner;
|
||
|
||
let goldens = golden_hashes(GOLDENS_AV1);
|
||
assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps-av1.nv12.sha256");
|
||
|
||
// The AU split the parity leg feeds the decoder: one IVF packet per temporal
|
||
// unit. AV1 carries no start codes, so this is the container's framing rather
|
||
// than something a scan could get subtly wrong — but a truncated or re-muxed
|
||
// vector would still shorten it silently.
|
||
let aus = common::split_av1_aus(common::TEST_25FPS_AV1);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"the vendored AV1 vector is {FRAME_COUNT} temporal units"
|
||
);
|
||
assert!(
|
||
aus.iter().all(|au| !au.is_empty()),
|
||
"no temporal unit is empty — an IVF reader that returned empty packets would \
|
||
make the parity leg decode nothing and blame the decoder"
|
||
);
|
||
|
||
// Walk the CPU planner over the same temporal units. It is the authority on how
|
||
// many frames the GPU leg can possibly deliver: the decoder builds exactly one
|
||
// delivered frame per `dpb.outputs` id, and AV1's planner has no `flush` tail.
|
||
let mut planner = Av1Planner::new();
|
||
let mut outputs = 0usize;
|
||
let mut coded_frames = 0usize;
|
||
let mut multi_frame_units = 0usize;
|
||
let mut show_existing = 0usize;
|
||
let mut warnings = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plans = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("temporal unit {index}: the clean vector must plan without errors, got {e:?}")
|
||
});
|
||
if plans.len() > 1 {
|
||
multi_frame_units += 1;
|
||
}
|
||
for plan in &plans {
|
||
coded_frames += 1;
|
||
outputs += plan.dpb.outputs.len();
|
||
warnings += plan.warnings.len();
|
||
// A `show_existing_frame` decodes nothing and stores nothing.
|
||
if plan.dpb.stored.is_none() {
|
||
show_existing += 1;
|
||
}
|
||
// Pin the picture shape both AV1 legs hard-code. They call
|
||
// `probe_stream_support(1, 8, false)` and assert an NV12 output format;
|
||
// a re-synced Main-10, 4:4:4 or film-grain vector would make both
|
||
// silently probe and expect the WRONG Vulkan decode profile on the
|
||
// fleet — grain synthesis is part of the PROFILE, not a per-frame
|
||
// toggle, so a grain-bearing vector is a different device requirement,
|
||
// not merely different pixels.
|
||
assert_eq!(
|
||
(
|
||
plan.picture.chroma_format_idc,
|
||
plan.picture.bit_depth,
|
||
plan.sequence.film_grain_params_present,
|
||
),
|
||
(1, 8, false),
|
||
"frame {coded_frames} (temporal unit {index}): the vendored AV1 vector \
|
||
must stay Main 4:2:0 8-bit with no film grain"
|
||
);
|
||
if coded_frames == 1 {
|
||
assert!(plan.picture.is_key, "the vector opens on a key frame");
|
||
// What `Readback` crops to, and what the goldens hash.
|
||
assert_eq!(
|
||
(plan.picture.render_width, plan.picture.render_height),
|
||
DISPLAY_AV1,
|
||
"the display (render) region the readback asserts against"
|
||
);
|
||
// …and what the pool allocates. Equal to the render region here, so
|
||
// the vector needs no AV1 conformance-window equivalent — the golden
|
||
// header's claim.
|
||
assert_eq!(
|
||
(plan.picture.upscaled_width, plan.picture.frame_height),
|
||
DISPLAY_AV1,
|
||
"the decoded (post-superres) picture IS the display region for \
|
||
this vector — coded size and render size coincide"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
assert_eq!(
|
||
outputs,
|
||
goldens.len(),
|
||
"the planner outputs {outputs} pictures but the goldens carry {} hashes — the \
|
||
parity leg's frame-count assertion would fail on hardware for a reason that \
|
||
has nothing to do with the GPU",
|
||
goldens.len()
|
||
);
|
||
assert_eq!(
|
||
coded_frames,
|
||
AV1_CODED_FRAME_COUNT,
|
||
"the vendored AV1 vector codes {AV1_CODED_FRAME_COUNT} frames; {} of them are \
|
||
hidden, which is why the goldens are {FRAME_COUNT} and not {coded_frames}",
|
||
AV1_CODED_FRAME_COUNT - FRAME_COUNT
|
||
);
|
||
assert_eq!(
|
||
multi_frame_units, 24,
|
||
"24 temporal units carry two frames each — the hidden ALTREFs, and the only \
|
||
reason AV1's `plan_au` returns a vector at all. If this reaches 0 the parity \
|
||
leg has stopped exercising multi-frame temporal units while still passing"
|
||
);
|
||
assert_eq!(
|
||
show_existing, 0,
|
||
"this vector uses no `show_existing_frame`; if that ever changes, frames start \
|
||
being displayed by a route the decoder handles differently and the display \
|
||
order the goldens assume needs rederiving"
|
||
);
|
||
assert_eq!(
|
||
warnings, 0,
|
||
"a clean conformance vector must plan without concealment — any warning here \
|
||
means the parity leg would be hashing concealed pixels against a clean \
|
||
reference"
|
||
);
|
||
}
|
||
|
||
/// The vendored frame-0 pixels ARE the first golden — not a second opinion about it.
|
||
///
|
||
/// [`AV1_FRAME0`] is the one place in this file where reference PIXELS live rather
|
||
/// than hashes, and pixels are exactly the kind of file that rots: regenerate the
|
||
/// goldens from a re-synced vector and this blob keeps describing the old one, while
|
||
/// the diagnostic leg that reads it goes on confidently naming the wrong cause. So
|
||
/// its digest is re-derived here and compared against `GOLDENS_AV1`'s first line —
|
||
/// the trusted, three-way cross-checked set — on every platform, with no GPU.
|
||
///
|
||
/// It also pins the layout the diagnostic's arithmetic assumes: 320x240 tightly
|
||
/// packed NV12 is 115200 bytes, luma first.
|
||
#[test]
|
||
fn the_av1_frame0_reference_is_the_first_golden() {
|
||
let (width, height) = (DISPLAY_AV1.0 as usize, DISPLAY_AV1.1 as usize);
|
||
assert_eq!(
|
||
AV1_FRAME0.len(),
|
||
width * height * 3 / 2,
|
||
"data/test-25fps-av1.frame0.nv12 must be one tightly packed NV12 frame of \
|
||
the vector's render region"
|
||
);
|
||
let goldens = golden_hashes(GOLDENS_AV1);
|
||
assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps-av1.nv12.sha256");
|
||
assert_eq!(
|
||
sha256_hex(AV1_FRAME0),
|
||
goldens[0],
|
||
"the vendored frame-0 pixels must hash to the AV1 golden set's FIRST entry — \
|
||
if they no longer do, the blob is from a different decode than the goldens \
|
||
and `av1_frame0_pixels_say_which_plane_and_how_badly` would attribute a \
|
||
divergence to the wrong cause. Regenerate it alongside the goldens: decode \
|
||
the vector with `-f rawvideo -pix_fmt nv12 -fps_mode passthrough` and take \
|
||
the first 115200 bytes (the golden file's header carries the full command)"
|
||
);
|
||
// Not a flat blob: a frame of one repeated byte would satisfy a length check and
|
||
// make every per-plane statistic in the diagnostic meaningless.
|
||
let luma = &AV1_FRAME0[..width * height];
|
||
let chroma = &AV1_FRAME0[width * height..];
|
||
assert!(
|
||
luma.iter().any(|b| *b != luma[0]) && chroma.iter().any(|b| *b != chroma[0]),
|
||
"both planes must carry real picture content"
|
||
);
|
||
}
|
||
|
||
/// Count Annex-B start codes in `stream` as `(total, three_byte)`.
|
||
///
|
||
/// Emulation prevention guarantees `00 00 01` cannot occur inside a NAL payload,
|
||
/// so every hit is a real prefix; a hit not preceded by a zero byte is a
|
||
/// three-byte one.
|
||
fn annexb_prefixes(stream: &[u8]) -> (usize, usize) {
|
||
let mut total = 0;
|
||
let mut three_byte = 0;
|
||
for i in 0..stream.len().saturating_sub(2) {
|
||
if stream[i..i + 3] == [0x00, 0x00, 0x01] {
|
||
total += 1;
|
||
if i == 0 || stream[i - 1] != 0x00 {
|
||
three_byte += 1;
|
||
}
|
||
}
|
||
}
|
||
(total, three_byte)
|
||
}
|
||
|
||
// The two guards below are what stop the four-byte hardware legs from passing
|
||
// vacuously. Those legs assert that a rewritten vector decodes to the SAME
|
||
// goldens as the original — which is trivially true if the rewrite quietly
|
||
// returned its input, or dropped NALs the planner never missed. Nothing on the
|
||
// fleet would notice; these notice in ordinary CI, with the reason.
|
||
|
||
#[test]
|
||
fn the_h264_four_byte_rewrite_changes_prefixes_and_nothing_else() {
|
||
use pf_bitstream::h264::H264Planner;
|
||
|
||
let original = common::TEST_25FPS_H264;
|
||
let rewritten = common::h264_four_byte_start_codes(original);
|
||
|
||
let (original_total, original_three) = annexb_prefixes(original);
|
||
let (rewritten_total, rewritten_three) = annexb_prefixes(&rewritten);
|
||
|
||
assert!(
|
||
original_three > 0,
|
||
"the vendored H.264 vector is supposed to carry THREE-byte start codes; \
|
||
if it no longer does, `h264_four_byte_start_codes_decode_bit_identically` \
|
||
is feeding the hardware the same bytes as the leg above it and proves \
|
||
nothing"
|
||
);
|
||
assert_eq!(
|
||
rewritten_three, 0,
|
||
"every start code in the rewritten stream must be four-byte — {rewritten_three} \
|
||
of {rewritten_total} are not"
|
||
);
|
||
assert_eq!(
|
||
rewritten_total, original_total,
|
||
"the rewrite must preserve the NAL count exactly ({original_total}), not \
|
||
drop or invent units"
|
||
);
|
||
assert!(
|
||
rewritten.len() > original.len(),
|
||
"widening every prefix cannot shrink the stream"
|
||
);
|
||
|
||
// Same access units, same planner verdict: the rewrite changed the framing
|
||
// and nothing the decoder acts on.
|
||
let aus = common::split_h264_aus(&rewritten);
|
||
assert_eq!(
|
||
aus.len(),
|
||
common::split_h264_aus(original).len(),
|
||
"the rewritten stream must split into the same access units"
|
||
);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"…and there are {FRAME_COUNT} of them"
|
||
);
|
||
|
||
let mut planner = H264Planner::new();
|
||
let mut outputs = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("AU {index}: the four-byte rewrite must plan as the original does, got {e:?}")
|
||
});
|
||
outputs += plan.dpb.outputs.len();
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
assert_eq!(
|
||
outputs, FRAME_COUNT,
|
||
"the rewritten vector must still output {FRAME_COUNT} pictures"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_h265_four_byte_rewrite_changes_prefixes_and_nothing_else() {
|
||
use pf_bitstream::h265::H265Planner;
|
||
|
||
let original = common::TEST_25FPS_H265;
|
||
let rewritten = common::h265_four_byte_start_codes(original);
|
||
|
||
let (original_total, original_three) = annexb_prefixes(original);
|
||
let (rewritten_total, rewritten_three) = annexb_prefixes(&rewritten);
|
||
|
||
assert!(
|
||
original_three > 0,
|
||
"the vendored H.265 vector is supposed to carry THREE-byte start codes; \
|
||
if it no longer does, `h265_four_byte_start_codes_decode_bit_identically` \
|
||
proves nothing"
|
||
);
|
||
assert_eq!(
|
||
rewritten_three, 0,
|
||
"every start code in the rewritten stream must be four-byte — {rewritten_three} \
|
||
of {rewritten_total} are not"
|
||
);
|
||
assert_eq!(
|
||
rewritten_total, original_total,
|
||
"the rewrite must preserve the NAL count exactly ({original_total})"
|
||
);
|
||
assert!(
|
||
rewritten.len() > original.len(),
|
||
"widening every prefix cannot shrink the stream"
|
||
);
|
||
|
||
let aus = common::split_h265_aus(&rewritten);
|
||
assert_eq!(
|
||
aus.len(),
|
||
common::split_h265_aus(original).len(),
|
||
"the rewritten stream must split into the same access units"
|
||
);
|
||
assert_eq!(
|
||
aus.len(),
|
||
FRAME_COUNT,
|
||
"…and there are {FRAME_COUNT} of them"
|
||
);
|
||
|
||
let mut planner = H265Planner::new();
|
||
let mut outputs = 0usize;
|
||
for (index, au) in aus.iter().enumerate() {
|
||
let plan = planner.plan_au(au).unwrap_or_else(|e| {
|
||
panic!("AU {index}: the four-byte rewrite must plan as the original does, got {e:?}")
|
||
});
|
||
outputs += plan.dpb.outputs.len();
|
||
}
|
||
outputs += planner.flush().outputs.len();
|
||
assert_eq!(
|
||
outputs, FRAME_COUNT,
|
||
"the rewritten vector must still output {FRAME_COUNT} pictures"
|
||
);
|
||
}
|