Merge pull request 'Three decode rungs were decoding into a surface they were predicting from' (#102) from integration/decode-aliasing-program into main
ci / bun-nix (push) Successful in 30s
ci / web (push) Successful in 1m22s
ci / docs-site (push) Successful in 1m24s
apple / swift (push) Successful in 1m35s
ci / rust-arm64 (push) Successful in 1m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 16s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 16s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 3m6s
windows-msix / package (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m11s
android / android (push) Successful in 5m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m37s
apple / screenshots (push) Successful in 5m52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m24s
deb / build-publish-host (push) Successful in 6m20s
ci / rust (push) Canceled after 6m56s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / package (push) Failing after 1m37s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 5m39s
windows-msix / package (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m47s
arch / build-publish (push) Successful in 11m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m37s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 10m20s
flatpak / build-publish (push) Successful in 8m54s

Reviewed-on: #102
This commit was merged in pull request #102.
This commit is contained in:
2026-08-07 23:11:28 +00:00
31 changed files with 9010 additions and 611 deletions
+2 -2
View File
@@ -69,8 +69,8 @@ Video, then the platform's own (pf-dxvadec on Windows, pf-vaadec on Linux), then
rung (openh264/rav1d). The libavcodec rungs that used to sit under each of them are
deleted, along with `pf-ffvk` and the `ffmpeg-next` dependency.
Two of the native rungs have never decoded a frame on real hardware (native VAAPI at all;
native D3D11VA's AV1 leg). They run anyway — with the libavcodec twins gone, the only
One of the native rungs has never decoded a frame on real hardware (native VAAPI's H.264 and
H.265 legs; its AV1 leg has decoded but has never been parity-checked). It runs anyway — with the libavcodec twins gone, the only
thing below them is the CPU, so barring them would cost the session hardware decode
outright rather than move it one rung down. What replaces the safety net is the log: every
session names the rung it landed on with its evidence state,
+252 -13
View File
@@ -240,6 +240,16 @@ pub enum PlanWarning {
/// hosts never emit MMCO 5, so this warning is the field signal if that
/// assumption ever breaks.
Mmco5Rebase,
/// The SPS carried no VUI `bitstream_restriction`, so the DPB had to be sized from
/// A.3.1's LEVEL ceiling — and that ceiling demands more hardware slots than a
/// mainstream decoder provides. See [`dpb_limit`] for why this is the H.264 shape
/// of the defect #96 fixed for HEVC, and why it is a warning here rather than a
/// clamp. Every H.264 encoder a punktfunk host can reach writes the restriction
/// (measured 2026-08-07), so this warning is the field signal if that ever breaks.
LevelDerivedDpb {
max_dpb_frames: usize,
level_idc: u8,
},
}
/// The AU cannot be planned at all.
@@ -296,12 +306,72 @@ impl From<&Sps> for NegotiationInfo {
bit_depth_luma_minus8: sps.bit_depth_luma_minus8,
bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8,
chroma_format_idc: sps.chroma_format_idc,
max_dpb_frames: sps.max_dpb_frames(),
max_dpb_frames: dpb_limit(sps),
interlaced: !sps.frame_mbs_only_flag,
}
}
}
/// The hardware DPB slot count a mainstream decoder provides. NVIDIA's Vulkan Video
/// reports `maxDpbSlots = 16` (RADV 17), and DXVA/D3D11VA's H.264 picture-parameter
/// format indexes the DPB with a 16-entry array. Backends allocate one slot per DPB
/// frame plus one for the picture in flight, so a stream whose DPB is sized at 16
/// demands 17 and is refused outright — losing the codec, not merely a slower path.
const MAINSTREAM_MAX_DPB_SLOTS: usize = 16;
/// DPB size in frames — the same question [`crate::h265::dpb_limit`] answers, and the
/// same trap, but H.264 gets a different answer and it is worth writing down why.
///
/// HEVC's SPS states its own requirement outright (`sps_max_dec_pic_buffering_minus1`),
/// so #96 could simply stop consulting equation A-2's level ceiling. H.264 has no such
/// unconditional field: the stream declares its need ONLY in the VUI's
/// `bitstream_restriction` (`max_dec_frame_buffering`, E.2.1). Absent that, A.3.1's
/// level ceiling — `min(MaxDpbMbs / (PicWidthInMbs * FrameHeightInMbs), 16)` — is all
/// the spec leaves, and it is genuinely the correct inference, not a bug. It is also
/// what the stream MAY use rather than what it needs, which is exactly the shape that
/// killed HEVC at 720p and 1080p.
///
/// The ceiling saturates at 16 — thus [`MAINSTREAM_MAX_DPB_SLOTS`] + 1 hardware slots,
/// the fatal value — whenever the picture is small relative to its level's `MaxDpbMbs`:
///
/// | picture | ceiling by level |
/// |---|---|
/// | 720p (3600 MBs) | L3.2 → 5, L4.2 → 9, **L5.0+ → 16** |
/// | 1080p (8160 MBs) | L4.2 → 4, L5.0 → 13, **L5.1+ → 16** |
/// | 1440p (14400 MBs) | L5.1 → 12, **L6.0+ → 16** |
/// | 2160p (32400 MBs) | L5.2 → 5, **L6.0+ → 16** |
///
/// So H.264 escaped #96 twice over rather than by one piece of luck, and BOTH escapes
/// are properties of the encoders, not of the format. Measured 2026-08-07 by reading
/// the SPS each encoder actually emitted, at 720p/1080p/1440p/2160p:
///
/// | encoder | level picked | `bitstream_restriction` | `max_dec_frame_buffering` |
/// |---|---|---|---|
/// | NVENC (RTX 5070 Ti, 610.57.04) | 3.2 / 4.2 / 5.1 / 5.2 | present | 3 |
/// | VAAPI via libavcodec (RDNA3, Mesa 26.0.3) | 4.1 / 4.2 / 5.1 / 5.2 | present | 1 |
/// | openh264 (software rung) | 3.2 / 4.2 / 5.1 / 5.2 | present | 1 |
///
/// Every one of them picks a level proportionate to the picture AND states its real
/// need in the VUI, so the ceiling is never reached and never consulted. That is why
/// this function does NOT clamp: with the restriction present the value below IS the
/// stream's own statement, and clamping a stream that genuinely asked for a deep DPB
/// would corrupt its output. Absent the restriction there is no honest smaller number
/// to substitute — [`PlanWarning::LevelDerivedDpb`] names the situation instead, so the
/// field tells us if a driver ever stops writing the VUI, rather than a user silently
/// losing H.264 the way #96's users silently lost HEVC.
fn dpb_limit(sps: &Sps) -> usize {
// A.3.1's cap, the VUI override and the `max_num_ref_frames` floor all live in the
// vendored parser; this is the one named place the RESULT is interpreted.
sps.max_dpb_frames()
}
/// Whether [`dpb_limit`] had to fall back to A.3.1's level ceiling because the SPS
/// carried no VUI `bitstream_restriction` — i.e. whether the number is the stream's own
/// statement of need or merely the largest DPB its level permits.
fn dpb_is_level_derived(sps: &Sps) -> bool {
!(sps.vui_parameters_present_flag && sps.vui_parameters.bitstream_restriction_flag)
}
#[derive(Copy, Clone, Debug)]
enum RefPicList {
RefPicList0,
@@ -606,7 +676,7 @@ impl H264Planner {
// uncapped. No hardware decoder implements a deeper DPB — a larger value is a
// corrupt (or hostile) VUI, not a feature request — and backends size real
// slot pools from this number, so it is gated here, at SPS activation.
if sps.max_dpb_frames() > 16 {
if dpb_limit(sps) > MAINSTREAM_MAX_DPB_SLOTS {
return Err(PlanError::OutsideEnvelope(
"DPB deeper than 16 frames (max_dec_frame_buffering)",
));
@@ -1279,10 +1349,19 @@ impl H264Planner {
}
// Apply the parameters of `sps` to the planning state.
fn apply_sps(&mut self, sps: &Sps) {
fn apply_sps(&mut self, sps: &Sps, warnings: &mut Vec<PlanWarning>) {
self.negotiation_info = NegotiationInfo::from(sps);
let max_dpb_frames = sps.max_dpb_frames();
let max_dpb_frames = dpb_limit(sps);
// Sized from the level ceiling rather than the stream's own declaration, and
// large enough that backends will ask for more slots than they can get. See
// [`dpb_limit`]: warned, not clamped, because there is no honest smaller number.
if dpb_is_level_derived(sps) && max_dpb_frames + 1 > MAINSTREAM_MAX_DPB_SLOTS {
warnings.push(PlanWarning::LevelDerivedDpb {
max_dpb_frames,
level_idc: sps.level_idc as u8,
});
}
let interlaced = !sps.frame_mbs_only_flag;
let max_num_order_frames = sps.max_num_order_frames() as usize;
let max_num_reorder_frames = if max_num_order_frames > max_dpb_frames {
@@ -1300,13 +1379,17 @@ impl H264Planner {
*old_negotiation_info != negotiation_info
}
fn renegotiate_if_needed(&mut self, sps: &Sps) -> Result<(), PlanError> {
fn renegotiate_if_needed(
&mut self,
sps: &Sps,
warnings: &mut Vec<PlanWarning>,
) -> Result<(), PlanError> {
if Self::negotiation_possible(sps, &self.negotiation_info) {
Self::check_envelope(sps)?;
// Make sure all the frames planned so far are display-ready before the
// stream parameters change under them.
self.drain_dpb();
self.apply_sps(sps);
self.apply_sps(sps, warnings);
}
Ok(())
@@ -1412,7 +1495,7 @@ impl H264Planner {
)?);
// A picture's SPS may require renegotiation.
self.renegotiate_if_needed(&pps.sps)?;
self.renegotiate_if_needed(&pps.sps, warnings)?;
let first_field = self.find_first_field(hdr).map_err(PlanError::Parse)?;
@@ -1706,7 +1789,7 @@ impl H264Planner {
bit_depth_luma_minus8: sps.bit_depth_luma_minus8,
bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8,
chroma_format_idc: sps.chroma_format_idc,
max_dpb_frames: sps.max_dpb_frames(),
max_dpb_frames: dpb_limit(sps),
recovery_point,
}
}
@@ -1900,6 +1983,21 @@ mod tests {
(sps, pps)
}
/// The warnings a plan carries, minus the DPB-sizing signal.
///
/// [`base_sps`]'s fixtures are 64x64 with no VUI bitstream restriction, so A.3.1's
/// ceiling saturates (`MaxDpbMbs(L1) = 396` over 16 macroblocks) and every plan
/// built on them carries [`PlanWarning::LevelDerivedDpb`]. That is the arithmetic
/// `the_level_ceiling_alone_would_reproduce_96_and_is_warned_about` exists to pin —
/// SMALL pictures saturate the ceiling most easily, not large ones — and it says
/// nothing about the picture, which is what the tests below are checking.
fn picture_warnings(plan: &AuPlan) -> Vec<&PlanWarning> {
plan.warnings
.iter()
.filter(|w| !matches!(w, PlanWarning::LevelDerivedDpb { .. }))
.collect()
}
fn param_set_au(sps: &Sps, pps: &Pps) -> Vec<u8> {
let mut au = Vec::new();
Synthesizer::<'_, Sps, _>::synthesize(3, sps, &mut au, true).unwrap();
@@ -2022,7 +2120,7 @@ mod tests {
let p4 = planner.plan_au(&au4).unwrap();
for plan in [&p0, &p1, &p2, &p3, &p4] {
assert!(
plan.warnings.is_empty(),
picture_warnings(plan).is_empty(),
"authored stream must plan clean: {plan:?}"
);
}
@@ -2101,7 +2199,10 @@ mod tests {
let p1 = planner.plan_au(&au1).unwrap();
let p2 = planner.plan_au(&au2).unwrap();
for plan in [&p0, &p1, &p2] {
assert!(plan.warnings.is_empty(), "must plan clean: {plan:?}");
assert!(
picture_warnings(plan).is_empty(),
"must plan clean: {plan:?}"
);
}
let idr_id = p0.dpb.stored.unwrap();
let lt_id = p1.dpb.stored.unwrap();
@@ -2364,6 +2465,144 @@ mod tests {
);
}
/// An SPS at a given picture size and level, with the VUI bitstream restriction
/// either absent (so [`dpb_limit`] must fall back to A.3.1's level ceiling) or
/// present with an explicit `max_dec_frame_buffering`.
fn sps_at(width: u32, height: u32, level: Level, declared: Option<u32>) -> Sps {
Sps {
profile_idc: Profile::Main as u8,
level_idc: level,
frame_mbs_only_flag: true,
direct_8x8_inference_flag: true,
// Every H.264 encoder measured below sits at or under this; it is the
// A.3.1 floor `max_dpb_frames` applies, never the value under test.
max_num_ref_frames: 3,
pic_width_in_mbs_minus1: (width / 16 - 1) as u16,
pic_height_in_map_units_minus1: (height / 16 - 1) as u16,
vui_parameters_present_flag: declared.is_some(),
vui_parameters: VuiParams {
bitstream_restriction_flag: declared.is_some(),
max_dec_frame_buffering: declared.unwrap_or(0),
..Default::default()
},
..Default::default()
}
}
/// The H.264 half of `pf-encode`'s `rfi_dpb_fits_a_mainstream_vulkan_decoder`.
///
/// That test guards the PRODUCER end for HEVC — that `RFI_DPB` never grows past
/// what a client can allocate. This is the CONSUMER end for H.264, where the
/// number is not ours to choose: the client derives it from whatever SPS arrives,
/// and #96 proved that deriving a DPB from a level's ceiling instead of a stream's
/// need costs the codec outright.
///
/// Pinned here are the (picture, level) pairs a punktfunk host can actually reach,
/// with the level each shipping encoder was MEASURED to pick on 2026-08-07 (see
/// [`dpb_limit`] for the full table and the hardware). The property that has to
/// hold is the one the backends enforce: `dpb_limit + 1 <= 16` slots.
#[test]
fn every_reachable_h264_stream_fits_a_mainstream_slot_pool() {
// (picture, level measured on NVENC / VAAPI / openh264, their VUI declaration)
let measured = [
((1280, 720), Level::L3_2, 3), // NVENC, openh264
((1280, 720), Level::L4_1, 1), // VAAPI via libavcodec
((1920, 1080), Level::L4_2, 3), // all three
((2560, 1440), Level::L5_1, 3),
((3840, 2160), Level::L5_2, 3),
];
for ((w, h), level, declared) in measured {
let sps = sps_at(w, h, level, Some(declared));
assert!(
!dpb_is_level_derived(&sps),
"{w}x{h} L{:?}: the measured encoders all write the VUI restriction — \
an SPS that carries it must never be treated as level-derived",
level
);
let slots = dpb_limit(&sps) + 1;
assert!(
slots <= MAINSTREAM_MAX_DPB_SLOTS,
"{w}x{h} L{level:?} declaring {declared} needs {slots} DPB slots, \
mainstream hardware caps at {MAINSTREAM_MAX_DPB_SLOTS}"
);
}
}
/// The cliff the measured encoders walk past, pinned so it stays visible.
///
/// These are the SAME resolutions, at levels a host could legally pick, with no
/// VUI restriction to state the real need. Each computes a 16-frame DPB — 17
/// hardware slots — which is precisely the arithmetic that killed HEVC at 720p and
/// 1080p before #96. H.264 escapes it because every encoder both picks a
/// proportionate level AND writes the restriction, not because the format is safe.
#[test]
fn the_level_ceiling_alone_would_reproduce_96_and_is_warned_about() {
// (picture, a level that saturates A.3.1's ceiling for it)
let cliff = [
((1280, 720), Level::L5),
((1280, 720), Level::L5_2),
((1920, 1080), Level::L5_1),
((2560, 1440), Level::L6),
((3840, 2160), Level::L6_2),
];
for ((w, h), level) in cliff {
let sps = sps_at(w, h, level, None);
assert!(dpb_is_level_derived(&sps), "{w}x{h} L{level:?}");
assert_eq!(
dpb_limit(&sps),
16,
"{w}x{h} L{level:?} should saturate A.3.1's 16-frame ceiling"
);
assert!(
dpb_limit(&sps) + 1 > MAINSTREAM_MAX_DPB_SLOTS,
"{w}x{h} L{level:?} is the #96 arithmetic and must be recognised as such"
);
// ...and the planner must NAME it rather than let a user silently lose the
// codec. The SPS activates on the first slice, so the warning rides out on
// the IDR's plan.
let sps = Rc::new(sps);
let pps = PpsBuilder::new(Rc::clone(&sps))
.pic_parameter_set_id(0)
.pic_init_qp(26)
.build();
let mut au = param_set_au(&sps, &pps);
au.extend(write_idr_slice());
let plan = H264Planner::new()
.plan_au(&au)
.unwrap_or_else(|e| panic!("{w}x{h} L{level:?} should plan, got {e:?}"));
assert!(
plan.warnings.contains(&PlanWarning::LevelDerivedDpb {
max_dpb_frames: 16,
level_idc: level as u8,
}),
"{w}x{h} L{level:?}: expected LevelDerivedDpb, got {:?}",
plan.warnings
);
}
}
/// A proportionate level is the other half of the escape: at the levels the
/// encoders actually pick, the ceiling is small enough that even a stream with no
/// VUI at all fits — so neither escape is doing all the work alone.
#[test]
fn a_proportionate_level_fits_even_without_a_vui_restriction() {
let proportionate = [
((1280, 720), Level::L3_2, 5),
((1280, 720), Level::L4_1, 9),
((1920, 1080), Level::L4_2, 4),
((2560, 1440), Level::L5_1, 12),
((3840, 2160), Level::L5_2, 5),
];
for ((w, h), level, expected) in proportionate {
let sps = sps_at(w, h, level, None);
assert_eq!(dpb_limit(&sps), expected, "{w}x{h} L{level:?}");
let slots = dpb_limit(&sps) + 1; // + the picture in flight
assert!(slots <= MAINSTREAM_MAX_DPB_SLOTS, "{w}x{h} L{level:?}");
}
}
/// MMCO 5 writer: op 5 takes NO argument (Table 7-9), so the generic
/// [`write_p_slice`] — whose supported ops all take exactly one — cannot author
/// it.
@@ -2688,11 +2927,11 @@ mod tests {
let plan = planner.plan_au(&write_idr_slice()).unwrap();
assert!(plan.picture.is_idr);
assert_eq!(plan.picture.pic_order_cnt, 0);
assert!(plan.warnings.is_empty());
assert!(picture_warnings(&plan).is_empty());
// And the stream continues cleanly on the reset state.
let plan = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
assert!(plan.warnings.is_empty());
assert!(picture_warnings(&plan).is_empty());
assert_eq!(plan.slices[0].ref_list0.len(), 1);
}
@@ -2725,7 +2964,7 @@ mod tests {
au.extend(write_idr_slice_at(8, 1));
let plan = H264Planner::new().plan_au(&au).unwrap();
assert!(plan.warnings.is_empty());
assert!(picture_warnings(&plan).is_empty());
assert_eq!(plan.slices.len(), 2);
// The picture parameters come from the FIRST slice's PPS (the uncropped
// SPS 0); they must not drift to the last slice's.
+10 -5
View File
@@ -165,11 +165,16 @@ windows = { git = "https://github.com/microsoft/windows-rs", rev = "acb5a1a74410
"winuser",
] }
[target.'cfg(windows)'.dev-dependencies]
# The native D3D11VA rung's frame-hash parity test compares decoded surfaces against the
# libavcodec goldens M5 captured — the same SHA-256 list, and the same crate, pf-vkdecode's
# Vulkan parity legs use (already in the workspace lock). The goldens are checked-in
# hashes; nothing links FFmpeg to read them.
[target.'cfg(any(target_os = "linux", windows))'.dev-dependencies]
# The two platform native rungs' frame-hash parity tests compare decoded surfaces against
# the libavcodec goldens M5 captured — the same SHA-256 list, and the same crate,
# pf-vkdecode's Vulkan parity legs use (already in the workspace lock). The goldens are
# checked-in hashes; nothing links FFmpeg to read them.
#
# Windows was the only platform here until the VAAPI rung grew a readback: `cfg(windows)`
# for `video_d3d11_native::parity`, now `cfg(linux)` as well for
# `video_vaapi_native::parity`. A DEV dependency, so no shipped binary gains anything —
# which is also part of why the VAAPI readback cannot reach the production video path.
sha2 = "0.10"
[features]
+2 -2
View File
@@ -105,8 +105,8 @@ pub mod clipboard;
pub mod video_d3d11;
// Native D3D11VA (M5): `ID3D11VideoDecoder` driven from pf-bitstream plans, filling the
// hand-off ring `video_d3d11` owns. Since M10 it is the only DXVA rung there is. In `auto`
// for the codecs that have hardware evidence (H.264/H.265) and, with nothing proven left
// below it, for AV1 too — see `video`'s evidence table; `PUNKTFUNK_DECODER=native-d3d11va`
// for all three codecs, each of which now has hardware evidence H.264/H.265 since M5, AV1
// since 2026-08-07 — see `video`'s evidence table; `PUNKTFUNK_DECODER=native-d3d11va`
// reaches every leg by pin.
#[cfg(windows)]
pub mod video_d3d11_native;
+146 -68
View File
@@ -16,15 +16,16 @@
//! M9's evidence FILTER survives, narrowed to the one thing it can still protect
//! ([`native_rung_admitted`]). The filter kept a rung that had never decoded on real
//! hardware out of `auto` while its proven libavcodec twin was one step below. With the
//! twins deleted that is usually no longer the situation: below native-d3d11va's AV1 leg,
//! and below native-vaapi on NVIDIA/AMD, there is nothing proven left to fall onto, so
//! barring the rung would not move a session one rung DOWN — it would take hardware decode
//! away from it entirely, which is the worse answer.
//! twins deleted that is usually no longer the situation: below native-vaapi on NVIDIA/AMD
//! there is nothing proven left to fall onto, so barring the rung would not move a session
//! one rung DOWN — it would take hardware decode away from it entirely, which is the worse
//! answer. (native-d3d11va's AV1 leg was the other standing example until 2026-08-07, when
//! it earned parity on two vendors and stopped being an unproven rung at all.)
//!
//! **One column of that table is different, and it is the one the filter still guards.**
//! On Linux, Intel and every unknown vendor id run `native-vaapi → native-vk → sw`
//! ([`VulkanDecodeDevice::prefer_vulkan_first`] is true for NVIDIA and AMD only), so the
//! rung directly below the never-run pf-vaadec is native Vulkan Video — H.264 and H.265 on
//! rung directly below the unproven pf-vaadec is native Vulkan Video — H.264 and H.265 on
//! three drivers plus a 92-minute soak, AV1 250/250. There, barring the unproven rung moves
//! the session exactly one rung down, onto proven code, so it is barred: an unproven rung
//! yields to a rung that is BOTH verified for this codec and usable on THIS device, and to
@@ -48,9 +49,9 @@
//! | native Vulkan Video | [`crate::video_vk_native`] | H.264 | **yes** — bit-exact vs libavcodec, 250/250 AUs on three drivers + a 92-minute soak (M2 WP-D) |
//! | native Vulkan Video | | H.265 (Main / Main10 / 4:4:4) | **yes** — same parity run + HDR chain and Deck/VanGogh legs (M3) |
//! | native Vulkan Video | | AV1 | **yes** — 250/250 bit-identical to libavcodec on an RTX 5070 Ti (M7); ONE vendor, no soak |
//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5) |
//! | native D3D11VA | | AV1 | **not proven** — it HAS now decoded (4K60, RTX 3500 Ada, 2026-08-07), but with no parity check and no soak it stays out of the admission filter. Its M7 wiring was right all along: what looked like a DXVA reference-mapping bug (`reference picture N holds no DPB slot`, 72 consecutive failures) was the HOST shipping half of every AV1 frame — see `pf_encode`'s `resolve_split_subframe` |
//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265, AV1 | **NO** — has never decoded a frame anywhere (M6/M7; no VAAPI hardware was reachable) |
//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5), re-confirmed 250/250 (+ 50/50 Main 10) on an RTX 3500 Ada and an Intel Arc on 2026-08-07 |
//! | native D3D11VA | | AV1 | **yes** — 250/250 delivered frames bit-identical to libavcodec on an RTX 3500 Ada AND an Intel Arc (2026-08-07). It got there from 186/250 and 245/250 DIVERGING frames on those same two GPUs: `plan_to_dxva_av1` released the picture this frame's own refresh displaces before assigning the decode target its slot, and `SlotMap::assign` hands back the slot just vacated — so 268 of the vector's 274 frames named one surface as both `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry. Intel followed the aliased surface (structurally wrong from display frame 4); NVIDIA tolerated it until the `order_hint` wrap at 64 made one 16x24 luma block depend on it. ONE defect, two driver tolerances — the two unlike signatures were not two bugs. TWO vendors, still NO soak on the goldens: the 5-minute 4K60 soak this row used to cite measured throughput, and "streams cleanly" was true throughout the failure |
//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265 (Main / Main 10), AV1 | **frame-hash parity-proven, ON ONE VENDOR** — 7 legs bit-identical to libavcodec on `.25` (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23) on 2026-08-08: vendored H.264 250/250, our host's low-delay H.264 120/120, vendored H.265 250/250, host low-delay H.265 120/120, HEVC Main 10 50/50 (P010), vendored AV1 250/250 of 274 decoded, host low-delay 4K two-tile AV1 60/60. The readback that made it possible is `vaDeriveImage` with a `vaCreateImage`+`vaGetImage` fallback, and it is `#[cfg(test)]`-only by construction — the production [`video_vaapi_native::Libva`] gains no entry point and a CPU test scans this crate's own source to keep it that way. **Not `verified`, and the reason is no longer parity**: one vendor and no soak. Flipping it moves `auto` on Linux AMD/Intel from Vulkan Video to VAAPI ([`native_rung_admitted`]) — an evidence-backed change, but a routing change, so it is made on purpose or not at all |
//! | software | `video_software` | H.264, AV1 | **not proven** — openh264 has never run on glass; rav1d HAS now decoded 1080p and 4K60 AV1 there (2026-08-07, .21) and recovers in-session from a mid-stream reference loss, but with no parity check and no soak. Its 4K "abort" was never about 4K: rav1d 1.1.0 kills the process on ANY decode error while it holds a single frame context, so `video_software` opens it with two — see [`crate::video_software`] |
//!
//! The software rung's evidence is recorded for the same reason but does not gate
@@ -240,8 +241,9 @@ pub struct DecodeHealth {
pub worst_run: u32,
/// Frames that decoded CORRECTLY and were then discarded without ever being
/// shown, because the backend's deliverable queue overflowed
/// (`video_vk_native::MAX_DELIVERABLE` — a decoder making more pictures
/// display-ready per access unit than the pump can take one at a time).
/// (`video_vk_native::MAX_DELIVERABLE` and `video_vaapi_native::max_deliverable`
/// — a decoder making more pictures display-ready per access unit than the pump
/// can take one at a time).
///
/// Deliberately its own number and not folded into any of the three above:
/// nothing was damaged, nothing was refused and no driver failed, so counting
@@ -251,8 +253,8 @@ pub struct DecodeHealth {
/// discarding a frame per AU is one running at half the frame rate it thinks
/// it is, and before this counter existed it read as perfectly clean.
///
/// Structurally 0 on every rung but native Vulkan — it is the only one with a
/// deliverable queue — and not on the session stats line today; the
/// Structurally 0 on every rung but native Vulkan and native VAAPI — the two with
/// a deliverable queue — and not on the session stats line today; the
/// rate-limited `warn` at the drop site is the field signal, and this is the
/// number a stats field would read.
pub dropped: u64,
@@ -771,7 +773,10 @@ enum Backend {
/// libavcodec's VAAPI hwaccel, and since M10 the only VAAPI rung: libva driven
/// straight from pf-bitstream plans, dlopen'd, exporting the same DRM-PRIME dmabufs.
/// Reachable by pin (`PUNKTFUNK_DECODER=native-vaapi`) and by `auto` in the vendor
/// order. ⚠ This rung has decoded NOTHING on hardware ([`native_evidence`]) — `auto`
/// order. ⚠ All four legs have now decoded on RDNA3 — 250/250/50/250 for
/// H.264/H.265/HEVC Main 10/AV1 — but every one of them is unverified for want of
/// frame-hash parity, which the tiled dmabuf makes impossible without a readback path
/// this rung does not have ([`native_evidence`]) — so `auto`
/// runs it where the alternative below it is the CPU, and yields to native Vulkan
/// Video where that rung is proven for the codec and usable on the device
/// ([`native_rung_admitted`], which is the Intel/unknown arm). Every session that
@@ -786,8 +791,9 @@ enum Backend {
/// `ID3D11VideoDecoder` driven from pf-bitstream plans, filling the shareable-RGBA
/// hand-off ring in `crate::video_d3d11`.
/// Reachable by pin (`PUNKTFUNK_DECODER=native-d3d11va`) and by `auto` in the vendor
/// order: its H.264/H.265 legs have hardware parity + a soak (M5); its AV1 leg has
/// decoded nothing anywhere and runs with the warning [`log_rung`] emits. Errors
/// order: its H.264/H.265 legs have hardware parity + a soak (M5), and its AV1 leg has
/// frame-hash parity on two vendors since 2026-08-07 (M7) — 250/250 after a decode
/// target that aliased a reference surface was fixed. Errors
/// ride the SAME streak/demotion machinery as every other hardware rung.
/// Boxed: the decoder (two planners plus a session) dwarfs the other variants.
#[cfg(windows)]
@@ -1109,20 +1115,68 @@ pub fn native_evidence(rung: NativeRung, wire: u8) -> RungEvidence {
true,
"frame-hash parity on an RTX 4090 and an AMD iGPU + 30-min soak (M5)",
),
// Decoded on hardware for the first time on 2026-08-07 (4K60, RTX 3500 Ada) once the
// host stopped truncating AV1 — so the old "NEVER decoded a frame anywhere" is no
// longer true and must not be printed. Still NOT `verified`: `verified` gates
// `native_rung_admitted`, i.e. whether `auto` may pick this rung AHEAD of Vulkan
// Video, and one 25-second session with no frame-hash parity and no soak does not
// buy that. Promoting it wants a `gpu_parity`-style run, deliberately.
// 2026-08-07: this pair failed its first parity check and now PASSES it, on both of
// the box's GPUs, after one defect was fixed.
//
// The failure was 186/250 diverging display frames on an RTX 3500 Ada and 245/250 on
// an Intel Arc, deterministic on three runs each. The two signatures looked like two
// defects — NVIDIA bit-exact through display frame 63 and then one 16x24 luma block
// (max |delta| 8, chroma untouched) at the frame whose `order_hint` first reaches 64;
// Intel structurally wrong from display frame 4 (47% of luma, max |delta| 242, chroma
// wrong too) with only its one PRIMARY_REF_NONE frame right. They were ONE defect and
// two driver tolerances.
//
// `plan_to_dxva_av1` released the pictures this frame's own `refresh_frame_flags`
// displaces INSIDE the conversion, then assigned the decode target a slot — and
// `SlotMap::assign` takes the lowest free slot, which is the one just vacated. So the
// submission named the same surface as `CurrPicTextureIndex` and as a
// `RefFrameMapTextureIndex` entry, on 268 of the vector's 274 frames: decode into the
// surface you are predicting from. AV1 applies `refresh_frame_flags` AFTER the frame
// is decoded (7.20), so that shape is ordinary rather than exotic; neither vendored
// H.264 nor H.265 vector ever produces it, which is why an eager release survived two
// hardware-proven codecs. Intel followed the aliased surface, NVIDIA tolerated it
// until the order-hint wrap made one block's prediction depend on it. `pf_dxvadec`'s
// `the_decode_target_never_aliases_a_surface_the_submission_names` is the CPU guard.
//
// ⚠ What this pair still does NOT have, unlike the H.264/HEVC one above: a soak on
// the goldens. The 5-minute 4K60 soak this note used to lean on measured throughput,
// not pixels, and "streams cleanly" is exactly what was true while 186 frames were
// wrong.
(NativeRung::D3d11va, CODEC_AV1) => (
false,
"decoded 4K60 once on an RTX 3500 Ada (2026-08-07) but has NEVER been \
parity-checked or soaked (M7)",
true,
"250/250 delivered frames bit-identical to libavcodec on an RTX 3500 Ada AND an \
Intel Arc (2026-08-07), after fixing a decode target that aliased a reference \
surface on 268 of 274 frames - two vendors, no soak (M7)",
),
// 2026-08-07: the VAAPI rung decoded its first frames ever, and by the end of that
// day ALL FOUR legs had — 250/250 of the vendored AV1 vector, then every access unit
// of the H.264 (250), H.265 (250) and HEVC Main 10 (50) vectors, on `.25` (Radeon
// 780M, RDNA3, Mesa 26.0.3): NV12 for the 8-bit legs, P010 for Main 10, all on a
// tiled AMD modifier. So "never decoded a frame anywhere" is no longer true of ANY of
// them and must not be printed. The arm stays split only because AV1's note carries
// its own frame count; both halves say the same thing about parity.
//
// 2026-08-08: parity finally exists here. A `#[cfg(test)]` readback
// (`vaDeriveImage`, falling back to `vaCreateImage` + `vaGetImage`) hashes the
// decoded surface, and all SEVEN legs came back bit-identical to libavcodec — the
// conformance vectors and our own host's low-delay streams, H.264 through AV1.
//
// So the old reason for `false` is gone, and the arm is no longer split: every leg
// has the same evidence. What is left is narrower and worth stating exactly, because
// the D3D11VA AV1 row above is a rung that decoded 250 frames and produced wrong
// pixels for every one of them — parity is what separated them, and this rung now
// has it. It stays `false` on ONE VENDOR and NO SOAK, and because flipping it is a
// routing change, not a bookkeeping one: `native_rung_admitted` would then let `auto`
// pick VAAPI ahead of Vulkan Video on every Linux AMD and Intel client, the Steam
// Deck included. That is defensible on this evidence and should be done deliberately,
// not as a side effect of recording a parity result.
(NativeRung::Vaapi, _) => (
false,
"NEVER decoded a frame on any hardware - no VAAPI device was reachable (M6/M7)",
"7 legs bit-identical to libavcodec on RDNA3 (Mesa 26.0.3, 2026-08-08) - H.264, \
H.265, HEVC Main 10 and AV1, on both the conformance vectors and our own host's \
low-delay streams - but has NEVER run on a second vendor and has never been \
soaked, and `verified` here would move `auto` off Vulkan Video on every Linux \
AMD/Intel client (M6/M7)",
),
// The 4K AV1 abort recorded here on 2026-08-07 is FIXED, and it was never about 4K.
// rav1d 1.1.0 aborts the process on ANY decode error while it holds a single frame
@@ -1174,17 +1228,20 @@ pub fn native_vulkan_usable(wire: u8, video_decode: bool, decode_video_caps: u32
/// Where the rule bites, and where it deliberately does not:
///
/// * **Linux, Intel and every unknown vendor id.** The order is `native-vaapi →
/// native-vk → sw`, so the rung under the never-run pf-vaadec is native Vulkan Video,
/// native-vk → sw`, so the rung under the unproven pf-vaadec is native Vulkan Video,
/// proven for all three codecs. Barring VAAPI there moves the session ONE rung down onto
/// proven code, so it is barred — and it stays reachable below Vulkan (the same ladder
/// reaches it again if Vulkan can't be built) and by pin.
/// * **Everything else.** Below the unproven rung is the CPU. Trading hardware decode for
/// software decode to avoid an unproven decoder is the worse answer, so those rungs run,
/// with the warning [`log_rung`] emits. That includes Windows Intel/unknown, where the
/// with the warning [`log_rung`] emits. That included Windows Intel/unknown, where the
/// rung below native-d3d11va IS native Vulkan Video on paper: that vendor family is the
/// one thing in this program with a MEASURED wrong-pixel report against Vulkan decode
/// (the B580, see [`Decoder::new`]), and "has never run" is not a reason to move a
/// session onto "known to strobe here". Callers say so where they pass `None`.
/// session onto "known to strobe here". Callers say so where they pass `None`. ⚠ Since
/// 2026-08-07 no D3D11VA leg is unproven, so that arm no longer exercises this clause —
/// the reasoning is kept because the `None` those callers pass is still what decides the
/// answer if any leg's evidence ever goes bad again.
///
/// ⚠ This governs `auto` ONLY. An explicit `PUNKTFUNK_DECODER=` pin bypasses it exactly as
/// it bypasses the vendor order — a pin is how a lab run reaches a rung `auto` will not
@@ -1444,8 +1501,13 @@ pub fn decodable_codecs() -> u8 {
/// * the presenter's Vulkan device advertises `DECODE_AV1` in its decode queue
/// family's codec operations, or
/// * (Windows) the presenter can import D3D11 textures — the native DXVA rung then decodes
/// AV1 Profile 0 through the adapter's profile GUID, and `auto` reaches it. That leg
/// has decoded nothing on hardware ([`native_evidence`]); the session says so at `warn`.
/// AV1 Profile 0 through the adapter's profile GUID, and `auto` reaches it. That leg is
/// frame-hash parity-proven on two vendors since 2026-08-07 ([`native_evidence`]); until
/// that run it was measured decoding WRONG PIXELS on both, and advertising AV1 was still
/// answered from device facts here — deliberately, because withdrawing the codec would be
/// a product call about what a Windows Intel box streams instead, not a fact about the
/// device. That reasoning is kept rather than deleted: it is the shape this arm has to
/// hold the next time a leg's evidence goes bad.
/// Before M10 this arm was conditional, because the leg was kept out of `auto` while
/// libavcodec's DXVA rung was still below it — with that rung deleted there is no
/// condition left to write.
@@ -1550,8 +1612,9 @@ fn report_au_fault_env(native_rung: bool) {
/// a frame through it for this codec.
///
/// This is the program's honesty surface, and M10 is where it earns its keep: every rung
/// is now native, two of them have never decoded anything anywhere, and there is no
/// libavcodec twin left underneath to catch a session that lands wrong. A field report of
/// is now native, one of them still has legs that have never decoded anything anywhere, one
/// leg that was measured decoding wrong pixels has since been fixed and proven, and there is
/// no libavcodec twin left underneath to catch a session that lands wrong. A field report of
/// the form "M10 broke my stream" is only actionable if the log distinguishes *the rung
/// with three drivers and a 92-minute soak behind it* from *the rung nothing has ever
/// run*, and the `stats:` decode-path tag — which is a machine interface and stays
@@ -1628,10 +1691,10 @@ impl Decoder {
/// Intel/unknown (Intel's driver advertises Vulkan Video, but Vulkan decode on it
/// strobed/overran the budget — B580 field report).
///
/// On top of that order sits the evidence filter ([`native_rung_admitted`]): a rung
/// that has never decoded a frame does not go FIRST when the rung directly below it is
/// proven for this codec and usable on this device. That is the Linux Intel/unknown
/// arm and only that arm — everywhere else what is below is the CPU.
/// On top of that order sits the evidence filter ([`native_rung_admitted`]): an
/// UNPROVEN rung does not go FIRST when the rung directly below it is proven for this
/// codec and usable on this device. That is the Linux Intel/unknown arm and only that
/// arm — everywhere else what is below is the CPU.
///
/// Whatever it lands on, the session logs `decode rung active` with the rung's name
/// and its evidence state, and that line is a WARNING when no hardware has ever
@@ -1918,8 +1981,9 @@ impl Decoder {
//
// Windows' D3D11VA RUNG: native D3D11VA (pf-dxvadec). Its H.264/H.265 legs HAVE
// hardware evidence (parity on an RTX 4090 and an AMD iGPU plus a 30-minute soak,
// M5); its AV1 leg has none and runs with the warning `done` logs — until M10 that
// leg was skipped in `auto` in favour of libavcodec's DXVA rung, which no longer
// M5), and since 2026-08-07 so does its AV1 leg (250/250 on an RTX 3500 Ada and an
// Intel Arc, no soak) — until M10 that leg was skipped in `auto` in favour of
// libavcodec's DXVA rung, which no longer
// exists. The rung needs the presenter's win32 import path or its frames could
// never reach the screen — that check is first, once.
#[cfg(windows)]
@@ -1976,6 +2040,19 @@ impl Decoder {
// so what is really below the DXVA AV1 leg is the CPU — and its H.264/H.265
// legs are verified anyway, which is what the first clause of
// [`native_rung_admitted`] answers.
//
// CLOSED, 2026-08-07 (opened and closed the same day): for a few hours this
// admitted a rung MEASURED to decode wrong pixels — 186/250 and 245/250
// diverging frames — and on Intel it is the arm that actually fires, because
// that vendor advertises no SAMPLED usage on any decode profile so zero-copy
// Vulkan Video cannot run there. The question it raised is worth keeping even
// though the answer expired: the filter asks "has this rung any evidence", and
// a rung with BAD evidence is a case the rule was never posed. It was left
// admitted rather than barred, because barring it trades visibly-wrong AV1 for
// the software rung, which cannot keep up at 4K (see [`crate::video_software`])
// and is itself unproven. The AV1 leg is now parity-proven on both vendors
// ([`native_evidence`]) so this arm admits a PROVEN rung today, and
// `native_rung_admitted`'s first clause would admit it whatever were below.
&& native_rung_admitted(NativeRung::D3d11va, wire, None)
{
d3d11_tried = true;
@@ -3234,8 +3311,8 @@ mod tests {
///
/// This test is the reason the table can be trusted a milestone from now. M9 turned
/// native rungs on by default and M10 deleted every libavcodec rung beneath them; the
/// argument for doing that honestly rests entirely on the claim "these five pairs are
/// proven and these six are not", and on the session log saying so. A
/// argument for doing that honestly rests entirely on the claim "these six pairs are
/// proven and these five are not", and on the session log saying so. A
/// table nobody checks drifts into a table that says everything is fine — which is
/// the exact failure this whole program exists to end, one layer up.
#[test]
@@ -3254,6 +3331,11 @@ mod tests {
),
(NativeRung::D3d11va, CODEC_H264, "native D3D11VA H.264 (M5)"),
(NativeRung::D3d11va, CODEC_HEVC, "native D3D11VA H.265 (M5)"),
(
NativeRung::D3d11va,
CODEC_AV1,
"native D3D11VA AV1 (M7, RTX 3500 Ada + Intel Arc, 2026-08-07)",
),
] {
assert!(
native_evidence(rung, codec).verified,
@@ -3261,25 +3343,20 @@ mod tests {
);
}
for (rung, codec, why) in [
(
NativeRung::D3d11va,
CODEC_AV1,
"the DXVA AV1 leg never ran (M7)",
),
(
NativeRung::Vaapi,
CODEC_H264,
"no VAAPI device was reachable",
"no VAAPI device has run this leg",
),
(
NativeRung::Vaapi,
CODEC_HEVC,
"no VAAPI device was reachable",
"no VAAPI device has run this leg",
),
(
NativeRung::Vaapi,
CODEC_AV1,
"no VAAPI device was reachable",
"VAAPI decoded AV1 on RDNA3 but has no parity check",
),
(
NativeRung::Software,
@@ -3330,20 +3407,20 @@ mod tests {
/// read against. Which of them `auto` may pick FIRST is
/// [`native_rung_admitted`]'s decision, asserted in the test after this one.
///
/// `(D3d11va, AV1)` stays here after 2026-08-07 even though it has now decoded on
/// hardware, and its warn line is why: it named the rung as unproven moments before that
/// rung failed 72 access units running, which is exactly the job this test protects. (The
/// cause was the HOST shipping half of every AV1 frame — `pf_encode`'s
/// `resolve_split_subframe` — not the rung.) Unproven is about EVIDENCE, not about whether
/// it has ever worked: one 25-second session with no parity check and no soak must not
/// promote a rung past Vulkan Video in the admission filter.
/// `(D3d11va, AV1)` LEFT this list on 2026-08-07, and the bar it had to clear is the
/// point. It had already decoded on hardware twice over — a 25-second session and a
/// 5-minute 4K60 soak on two GPUs — and it stayed unproven through both, because
/// unproven is about EVIDENCE and neither run looked at a pixel. What moved it was the
/// frame-hash parity check: 250/250 delivered frames bit-identical to libavcodec on an
/// RTX 3500 Ada AND an Intel Arc. Its first run of that check FAILED on both (186/250 and
/// 245/250 diverging), while the rung streamed 4K60 the whole time — so the warn line
/// this test protects was telling the truth right up to the run that retired it.
#[test]
fn every_rung_runs_and_the_unproven_ones_are_named() {
let unproven = [
(NativeRung::Vaapi, CODEC_H264),
(NativeRung::Vaapi, CODEC_HEVC),
(NativeRung::Vaapi, CODEC_AV1),
(NativeRung::D3d11va, CODEC_AV1),
];
for (rung, codec) in unproven {
let e = native_evidence(rung, codec);
@@ -3356,7 +3433,9 @@ mod tests {
assert!(
e.note.contains("NEVER") || e.note.contains("never"),
"{} / {codec:#x}: the note is what the session log prints at warn — it \
must say plainly that nothing has run it, got {:?}",
must name plainly what this pair has NEVER had, whether that is a \
hardware run at all (VAAPI H.264/H.265) or the parity check that would \
promote it (VAAPI AV1, which HAS decoded), got {:?}",
rung.name(),
e.note
);
@@ -3369,6 +3448,7 @@ mod tests {
(NativeRung::Vulkan, CODEC_AV1),
(NativeRung::D3d11va, CODEC_H264),
(NativeRung::D3d11va, CODEC_HEVC),
(NativeRung::D3d11va, CODEC_AV1),
] {
assert!(native_evidence(rung, codec).verified, "{}", rung.name());
}
@@ -3421,6 +3501,15 @@ mod tests {
(NativeRung::Vulkan, CODEC_AV1),
(NativeRung::D3d11va, CODEC_H264),
(NativeRung::D3d11va, CODEC_HEVC),
// Joined this list on 2026-08-07 with 250/250 on two vendors. ⚠ It is a
// BEHAVIOUR change on real machines and not only a label: this pair was the
// one the filter barred under `Some(Vulkan)`, so on Windows Intel and
// unknown-vendor boxes — where the ladder is `native-d3d11va → native-vk → sw`
// — `auto` now decodes AV1 on D3D11VA where it previously fell to Vulkan
// Video. Taken deliberately: the rung is ~10x the Vulkan leg's speed, and the
// parity that promoted it was measured on an Intel Arc, which is exactly the
// vendor family that change moves.
(NativeRung::D3d11va, CODEC_AV1),
] {
for below in [
None,
@@ -3435,17 +3524,6 @@ mod tests {
);
}
}
// Windows, Intel/unknown auto: the DXVA AV1 leg has never run, and the ladder
// passes `None` there on purpose — that vendor family is the one with a measured
// wrong-pixel report against Vulkan decode, so what is really below it is the CPU.
// This asserts the ARGUMENT the call site passes, which is where the judgement
// lives; `Some(Vulkan)` would bar it, and that is deliberately not what it passes.
assert!(native_rung_admitted(NativeRung::D3d11va, CODEC_AV1, None));
assert!(!native_rung_admitted(
NativeRung::D3d11va,
CODEC_AV1,
Some(NativeRung::Vulkan)
));
// The CPU rung is last everywhere, so nothing is ever below it and it always runs
// — including for a codec it has no decoder for, which is `last_rung_verdict`'s
// problem and not the filter's.
+599 -46
View File
@@ -18,10 +18,58 @@
//! (`video::native_evidence`, and the table in `video`'s module docs):
//!
//! * **H.264 and H.265** — frame-hash parity against libavcodec on an RTX 4090 and an AMD
//! iGPU plus a 30-minute soak (M5).
//! * **AV1** — wired in M7, has decoded nothing on any hardware. Until M10 `auto` skipped it
//! in favour of the libavcodec rung below; with that gone the alternative is the CPU, so it
//! runs and the session log says so at `warn`.
//! iGPU plus a 30-minute soak (M5), re-confirmed on an RTX 3500 Ada and an Intel Arc on
//! 2026-08-07 (250/250 both codecs, plus 50/50 HEVC Main 10 on both).
//!
//! ⚠⚠ **All of that was against ONE vendored vector per codec, and for H.264 the vector
//! was blind to a defect present on 99% of the frames we actually stream.** It reorders
//! and carries a 7-frame DPB against 2 reference frames; a punktfunk host emits
//! low-delay IPPP whose DPB is exactly as deep as its 3 reference frames, so 8.2.5's
//! sliding window unmarks a picture in the very access unit whose C.4.5.3 bump evicts
//! it. `plan_to_dxva` released that surface before assigning the decode target one, and
//! `SlotMap::assign` handed it straight back — `CurrPic` and a `RefFrameList` entry
//! naming one surface, on 117 of 120 access units. Found 2026-08-07 by planning our own
//! host's output on the CPU, fixed with the same deferral the AV1 rung got
//! ([`pf_dxvadec::DecodePlanDxva::release_after_decode`]), and the stream is now
//! vendored so `low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec`
//! holds the rung to what it streams rather than only to what it conforms to.
//!
//! HEVC is EXEMPT from that defect, and since 2026-08-07 that is a measurement rather
//! than an argument: `H265Planner` snapshots `dpb_refs` after `decode_rps`, so an
//! RPS-dropped picture never reaches `RefPicList`, and a vendored low-delay HEVC
//! stream from the same host confirms it — 115 of its 120 access units retire a
//! picture, 0 alias, and all 115 WOULD alias if the snapshot moved one call earlier.
//! `low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec` is the pixel
//! leg; pf-dxvadec's `pic_h265` tests pin the numbers and drive the counterfactual
//! through the conversion.
//! * **AV1** — wired in M7, and frame-hash parity on the SAME two GPUs since 2026-08-07:
//! 250/250 delivered frames bit-identical to libavcodec on the RTX 3500 Ada and on the
//! Intel Arc. It streams 4K60 on both with a clean 5-minute soak, but that is throughput
//! and not pixels — the leg streamed exactly as cleanly while 186 and 245 of those 250
//! frames were WRONG, which is what the first run of this harness measured on 2026-08-07
//! and what `av1_divergence_map` (below) records. The defect was one line of DPB
//! bookkeeping in [`pf_dxvadec::plan_to_dxva_av1`]: it released the picture this frame's
//! own `refresh_frame_flags` displaces before assigning the decode target a slot, and
//! `SlotMap::assign` hands back the slot just vacated, so 268 of the vector's 274 frames
//! named one surface as both `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry.
//! [`NativeD3d11Decoder::frame_av1`] now applies the conversion's
//! `release_after_decode` once the decode op is issued.
//!
//! Since 2026-08-07 a SECOND AV1 stream runs beside the vector: our own host's 4K
//! output, `low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec`. Not for
//! the aliasing — the vector covers that better than any host stream could — but
//! because every frame of the vector is `tile_cols = tile_rows = 1`, so every tile
//! array `plan_to_dxva_av1` fills had only ever been written at index 0. Our encoder
//! splits 4K into two tile rows carried in one Tile Group OBU, which is two tile
//! RECORDS from one group; 1440p and below measured single-tile, so 4K is the only
//! shape that has it.
//!
//! ⚠ Still no SOAK on the goldens, so this leg's evidence is two vendored streams on two
//! vendors — narrower than the H.264/H.265 legs above. ⚠⚠ And both are FILES. "250/250
//! delivered frames bit-identical" was true for the entire period the host was shipping
//! only the FIRST TILE of every 4K frame: that verification ran against a vendored file
//! while the truncation lived in packetisation, and this suite stayed green throughout.
//! Nothing here covers fragmentation, reassembly or loss.
//!
//! A refusal or an init failure logs and falls through to the standard ladder, so neither the
//! pin nor the `auto` admission can cost a session its decoder.
@@ -175,6 +223,20 @@ struct Submission {
/// other two codecs do not (see [`NativeD3d11Decoder::frame_av1`]). All three
/// conversions produce it; dropping it here made the AV1 leak invisible.
setup_id: u64,
/// Surfaces this picture's own end-of-picture bookkeeping retires while the
/// submission still NAMES them, released once the decode op has been issued
/// ([`NativeD3d11Decoder::release_deferred`]).
///
/// The caller's half of [`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`]
/// and [`pf_dxvadec::DecodePlanDxva::release_after_decode`]. Dropping it decodes
/// the picture into a surface it predicts from — 268 of the vendored AV1 vector's
/// 274 frames, and 297 of every 300 access units of low-delay H.264, which is what
/// every punktfunk host emits.
///
/// Empty on H.265 alone, and that is structural rather than lucky: `H265Planner`
/// snapshots `dpb_refs` AFTER `decode_rps`, so a picture this AU's RPS dropped is
/// never in the set `RefPicList` is built from.
release_after_decode: Vec<u64>,
/// Which codec's slice-control record the packer's locations become.
codec: Codec,
/// What the hand-off needs to blit this picture.
@@ -436,11 +498,21 @@ impl NativeD3d11Decoder {
// The plan needed a substitute for something lost. Fold it, ask for recovery,
// and do NOT submit: a concealed picture is not fit to present, and submitting
// it would put a wrong reference in the DPB for every AU after it.
//
// The deferred releases still run: they are the planner's verdict on
// pictures that left the DPB, and a converted-but-unsubmitted AU took its
// slot just the same.
self.release_deferred(&submission);
self.health.note(true, false, 0);
self.want_recovery = true;
return Ok(None);
}
let frame = match self.submit(au, &submission) {
let submitted = self.submit(au, &submission);
// The surfaces the conversion refused to release, freed now that the decode op
// has been issued (or has failed, where dropping them would leak just the
// same) — see [`Self::release_deferred`].
self.release_deferred(&submission);
let frame = match submitted {
Ok(frame) => frame,
Err(e) => {
self.health.note(false, true, 0);
@@ -452,6 +524,36 @@ impl NativeD3d11Decoder {
Ok(Some(frame))
}
/// Apply a submission's [`Submission::release_after_decode`] — the surfaces its
/// conversion held back because the submission still NAMED them.
///
/// Safe here and nowhere earlier: the decode op has been issued (or will never be),
/// so nothing can be assigned these surfaces before the next access unit is
/// converted. Dropping the list instead holds a surface per AU and reaches
/// `SlotError::Full` within the ledger's depth — which is why every exit of
/// [`Self::decode`] runs it, the concealed and failed ones included.
///
/// Empty on H.265, whose planner cannot produce the shape; populated on nearly
/// every H.264 and AV1 picture.
fn release_deferred(&mut self, sub: &Submission) {
let Some(session) = self.session.as_mut() else {
return;
};
for &id in &sub.release_after_decode {
if !session.slots.release(id) {
// Never fatal, and never silent — but `debug!` rather than `warn!`,
// because there is a LEGITIMATE way to get here: a renegotiation
// replaces the whole `Session` (and with it the slot map) inside
// `plan`, while the planner's own drain reports every drained picture
// in the same AU's `removed`. Those ids belong to the map that no
// longer exists, so every one of them misses and nothing is wrong.
// Outside a rebuild it means the conversion and the ledger disagree
// about the DPB, which the surrounding rebuild log makes separable.
tracing::debug!(id, "a deferred release named a picture holding no surface");
}
}
}
/// One AV1 **temporal unit**: decode every frame in it, present at most one.
///
/// This is the whole of what AV1 adds to this rung's contract, and it is the
@@ -542,6 +644,21 @@ impl NativeD3d11Decoder {
/// rung closes it in `pf_vkdecode::decoder_av1`; this is the same close, and it
/// runs on the concealed path too, because a converted-but-unsubmitted frame
/// took a slot just the same.
///
/// # The surfaces the conversion refuses to release
///
/// The second of the two slot releases below, and the caller's half of
/// [`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`]. AV1 applies
/// `refresh_frame_flags` AFTER the frame is decoded (7.20), so a frame that reads
/// a slot its own refresh overwrites is ordinary — 268 of the vendored vector's
/// 274 frames — and `plan_to_dxva_av1` therefore hands those pictures back rather
/// than releasing them, because `SlotMap::assign` would return the surface just
/// vacated to `setup_slot` and the submission would name one surface as both
/// `CurrPicTextureIndex` and a `RefFrameMapTextureIndex` entry. Releasing them
/// HERE is safe for the same reason the `refresh_frame_flags == 0` release below
/// is: the decode op has been issued, so nothing can be assigned them before the
/// next frame. Dropping them instead holds a surface per frame and exhausts the
/// nine-slot ledger within ten.
fn frame_av1(
&mut self,
au: &[u8],
@@ -554,6 +671,72 @@ impl NativeD3d11Decoder {
return self.show_existing_av1(plan);
}
let sub = self.plan_frame_av1(au, plan)?;
// ⚠ The decode's `Result` is held rather than `?`-ed, so that the two slot
// releases below run on the FAILURE path too. `decode_av1` treats an error
// here as a health note and keeps the session — it does not rebuild the slot
// map — so an early return leaked a surface per failed frame and would reach
// `SlotError::Full` after nine, a session dying of an error it had already
// recovered from.
//
// ⚠⚠ That closes THIS frame's leak and not the unit's: `decode_av1` returns on
// the first failing frame and abandons the rest of the temporal unit's plans,
// whose removals are then never released and whose stored ids are never
// assigned — the ledger and the planner's store desynchronise. 24 of the
// vendored vector's 250 units carry a second frame, so it is not hypothetical.
// Left as it is: recovering a partly-decoded unit means deciding what to do
// with the frames after the failure, which is the pump's question and not this
// function's, and the failure already ends in a keyframe request.
let shown = self.decode_and_present_av1(au, &sub, damaged);
if shown.is_err() {
// The surface's `held` entry, on the path that now CONTINUES rather than
// returning early. The slot map says this surface holds THIS picture while
// the surface still carries whatever the previous occupant decoded, so a
// later `show_existing_frame` naming it would blit the old picture's pixels
// with the old picture's geometry and colour. The `damaged` path has
// cleared it for that reason since M7; the failure path never reached this
// far before.
if let Some(session) = self.session.as_mut() {
if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) {
*held = None;
}
}
}
// The surfaces this frame's own refresh displaced while its submission still
// NAMED them (fn docs). Released here for the same reason the block below
// waits: the decode op has been issued, so nothing can be assigned them
// until the next frame — and on the `damaged` and failed paths there is no
// op at all, where dropping the release would leak a surface just the same.
self.release_deferred(&sub);
// The slot nothing will ever ask for again (fn docs). Released AFTER the
// blit above, so the surface is read before anything can be assigned it.
if plan.header.refresh_frame_flags == 0 {
if let Some(session) = self.session.as_mut() {
if session.slots.release(sub.setup_id) {
tracing::trace!(
id = sub.setup_id,
slot = sub.setup_slot,
"AV1 frame refreshes no reference slot — returning its surface"
);
}
if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) {
*held = None;
}
}
}
shown
}
/// Submit one converted AV1 frame and blit it if it displays — the part of
/// [`Self::frame_av1`] that can fail, split out so its caller can run the slot
/// releases on the failure path as well as on the two clean ones.
fn decode_and_present_av1(
&mut self,
au: &[u8],
sub: &Submission,
damaged: bool,
) -> Result<Option<D3d11Frame>> {
let shown = if damaged {
// Converted (so the slot map stayed in step with the planner's store),
// deliberately not submitted (fn docs).
@@ -572,7 +755,7 @@ impl NativeD3d11Decoder {
}
None
} else {
self.decode_into(au, &sub)?;
self.decode_into(au, sub)?;
if let Some(session) = self.session.as_mut() {
// What this surface now holds, for a later `show_existing_frame`.
if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) {
@@ -585,23 +768,6 @@ impl NativeD3d11Decoder {
None
}
};
// The slot nothing will ever ask for again (fn docs). Released AFTER the
// blit above, so the surface is read before anything can be assigned it.
if plan.header.refresh_frame_flags == 0 {
if let Some(session) = self.session.as_mut() {
if session.slots.release(sub.setup_id) {
tracing::trace!(
id = sub.setup_id,
slot = sub.setup_slot,
"AV1 frame refreshes no reference slot — returning its surface"
);
}
if let Some(held) = session.held.get_mut(usize::from(sub.setup_slot)) {
*held = None;
}
}
}
Ok(shown)
}
@@ -633,6 +799,7 @@ impl NativeD3d11Decoder {
slice_ranges: Vec::new(),
setup_slot: dxva.setup_slot,
setup_id: dxva.setup_id,
release_after_decode: dxva.release_after_decode,
codec: Codec::Av1,
facts: PictureFacts {
colour: colour_of(plan.picture.colour),
@@ -736,6 +903,20 @@ impl NativeD3d11Decoder {
slice_ranges: dxva.slice_ranges,
setup_slot: dxva.setup_slot,
setup_id: dxva.setup_id,
// ⚠⚠ The suspicion of 2026-08-07 was RIGHT, and the shape is the
// ordinary case rather than a corner: on every stream a punktfunk
// host emits, 297 of 300 access units name one surface as both
// `CurrPic` and a `RefFrameList` entry. `H264Planner` snapshots
// `dpb_refs` before 8.2.5's marking, and low-delay H.264 —
// `max_num_reorder_frames = 0`, so a picture is output the moment
// it decodes — puts the unmarking and the eviction in one AU.
// NVENC seals it by writing `max_num_ref_frames = 3` AND
// `max_dec_frame_buffering = 3`: a DPB exactly as deep as the
// reference count. The vendored vector cannot reach the shape (a
// level-derived DPB of 7 against 2 reference frames, and it
// reorders), which is why it measured zero for two milestones.
// See `pf_dxvadec::DecodePlanDxva::release_after_decode`.
release_after_decode: dxva.release_after_decode,
codec: Codec::H264,
facts: PictureFacts {
colour: colour_of(plan.picture.colour),
@@ -795,6 +976,16 @@ impl NativeD3d11Decoder {
slice_ranges: dxva.slice_ranges,
setup_slot: dxva.setup_slot,
setup_id: dxva.setup_id,
// HEVC is the one of the three that needs no deferral, and it is
// STRUCTURAL rather than measured: `H265Planner` snapshots
// `dpb_refs` AFTER `decode_rps` has updated the DPB, so a picture
// this AU's RPS dropped is never in the snapshot `RefPicList` is
// built from, and nothing later in the AU unmarks anything. Both
// other codecs snapshot BEFORE their marking, and both needed the
// deferral. Now measured as well as argued: a low-delay HEVC
// stream from the same host that aliases 297 of 300 H.264 access
// units aliases 0 of 300 here.
release_after_decode: Vec::new(),
codec: Codec::H265,
facts: PictureFacts {
colour: colour_of(plan.picture.colour),
@@ -1578,9 +1769,52 @@ mod parity {
/// rungs measured against two copies of a golden set is two measurements, and the
/// point of this file is that they are one.
const GOLDENS_H264: &str = include_str!("../../pf-vkdecode/tests/data/test-25fps.nv12.sha256");
/// **Our own host's low-delay H.264** and its goldens — the stream the vendored
/// vector cannot be. 120 pictures of 640x480 IPPP with `max_num_reorder_frames = 0`
/// and a DPB exactly as deep as its 3 reference frames, so 8.2.5's sliding window
/// unmarks the oldest reference in the very access unit whose C.4.5.3 bump evicts
/// it: `dpb.removed` and `dpb_refs` intersect on 117 of the 120, and the conversion
/// used to release those surfaces before assigning the decode target one.
///
/// The vendored vector passed 250/250 on four GPUs across two milestones while
/// that was true of every stream this program ships. Provenance, the
/// `punktfunk-host spike` command and the ffmpeg cross-check are in the golden
/// file's header.
const LOWDELAY_H264: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264");
const GOLDENS_LOWDELAY: &str =
include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480.nv12.sha256");
const LOWDELAY_FRAME_COUNT: usize = 120;
const GOLDENS_H265: &str =
include_str!("../../pf-vkdecode/tests/data/test-25fps-h265.nv12.sha256");
/// **Our own host's low-delay HEVC** and its goldens — the H.265 twin of
/// [`LOWDELAY_H264`], vendored for the opposite reason.
///
/// The H.264 stream is here because this rung was WRONG and only that shape could
/// show it. This one is here because HEVC is believed RIGHT — `H265Planner`
/// snapshots `dpb_refs` after `decode_rps`, so an RPS-dropped picture is never in
/// the marked set `RefPicList` is built from, and `plan_to_dxva_h265` is the one
/// conversion of the three that still releases inline. 120 pictures of 640x480
/// IPPP, `sps_max_num_reorder_pics = 0`, a five-picture DPB against four marked
/// references: 115 of the 120 access units retire a picture, `removed ∩ dpb_refs`
/// is 0 of 120, and all 115 would alias under the other snapshot ordering
/// (pf-dxvadec's `pic_h265` tests pin every one of those numbers, and drive the
/// counterfactual through the conversion itself).
///
/// Provenance, the `punktfunk-host spike` command and the ffmpeg cross-check are in
/// the golden file's header.
const LOWDELAY_H265: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h265");
const GOLDENS_LOWDELAY_H265: &str =
include_str!("../../pf-vkdecode/tests/data/lowdelay-640x480-h265.nv12.sha256");
/// The HEVC low-delay stream's frame count. A separate constant from
/// [`LOWDELAY_FRAME_COUNT`] on purpose: two files, two encoder runs, and one
/// regenerated at another length must fail on its own leg.
const LOWDELAY_H265_FRAME_COUNT: usize = 120;
/// Both vendored vectors are 250 display frames.
const FRAME_COUNT: usize = 250;
@@ -1614,6 +1848,42 @@ mod parity {
const AV1_DECODED_COUNT: usize = 274;
const AV1_SHOWN_COUNT: usize = 250;
/// The vendored AV1 vector's render region, and what its goldens hash.
const DISPLAY_AV1: (u32, u32) = (320, 240);
/// **Our own host's AV1**, and the only stream this rung decodes with more than
/// ONE TILE.
///
/// Unlike the H.264 and H.265 low-delay siblings this is not about the
/// release-ordering defect — the vendored vector already aliases on 268 of its 274
/// frames, which is exactly why parity caught that one here. It closes a different
/// gap: no host-generated AV1 stream had pixel coverage anywhere, and our encoder's
/// AV1 is structurally unlike the vector. At 4K the split encode emits
/// `tile_cols = 1, tile_rows = 2` — `height_in_sbs_minus_1 = [16, 16]` — with both
/// tiles in a SINGLE Tile Group OBU. 1440p and below measured single-tile, so 4K is
/// the only shape that has the property; 60 frames rather than 120 pays for it, at
/// 261 KB.
///
/// ⚠ A file fixture is not the wire path, and on AV1 that distinction has already
/// cost a release. "250/250 delivered frames bit-identical" was true for the whole
/// period the host was shipping only the first tile of every 4K frame — the
/// verification ran against a vendored file and the truncation lived in
/// packetisation. This leg gives the multi-tile shape pixel coverage on the DECODE
/// rung and proves nothing about fragmentation, reassembly or loss.
const LOWDELAY_AV1: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1");
const GOLDENS_LOWDELAY_AV1: &str =
include_str!("../../pf-vkdecode/tests/data/lowdelay-3840x2160-av1.nv12.sha256");
/// 60 units, 60 decoded, 60 shown — three constants, never derived from each
/// other. Our host emits one shown frame per temporal unit with no hidden frames
/// and no `show_existing_frame`, which is the OPPOSITE shape to the vendored
/// vector's 250 / 274 / 250 and the reason the harness takes all three.
const LOWDELAY_AV1_UNIT_COUNT: usize = 60;
const LOWDELAY_AV1_DECODED_COUNT: usize = 60;
const LOWDELAY_AV1_SHOWN_COUNT: usize = 60;
const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160);
/// The golden file's hash lines (comments and blanks skipped).
fn golden_hashes(file: &'static str) -> Vec<&'static str> {
file.lines()
@@ -1821,7 +2091,7 @@ mod parity {
/// the whole difference. `display` is still the planner's own output list;
/// AV1 has no bumping process, so a picture is output by the unit that shows
/// it and there is no flush to drain at the end.
fn order_av1(units: &[&[u8]]) -> Order {
fn order_av1(units: &[&[u8]], render: (u32, u32)) -> Order {
let mut planner = pf_dxvadec::Av1Planner::new();
let mut order = Order {
decode: Vec::new(),
@@ -1841,8 +2111,8 @@ mod parity {
);
assert_eq!(
(plan.picture.render_width, plan.picture.render_height),
(320, 240),
"unit {index}: the goldens are the 320x240 render region"
render,
"unit {index}: the goldens are the {render:?} render region"
);
if let Some(id) = plan.dpb.stored {
order.decode.push(id);
@@ -2052,6 +2322,11 @@ mod parity {
decoder
.submit(au, &sub)
.unwrap_or_else(|e| panic!("AU {index}: submit failed — {e:#}"));
// This harness drives `plan` + `submit` rather than `decode`, so it owes
// the deferred releases `decode` would have applied. Not optional
// bookkeeping: on a low-delay stream nearly every AU defers, and a loop
// that drops them exhausts the ledger within the DPB's depth.
decoder.release_deferred(&sub);
let session = decoder.session.as_ref().expect("submit built a session");
let pool = session.pool.clone();
let bytes = readback.read(&decoder.device, &pool, slice, display);
@@ -2124,18 +2399,45 @@ mod parity {
/// ⚠ Still unexercised, because the vendored vector has none:
/// `show_existing_frame`.
fn av1_parity_run(units: &[&[u8]], order: &Order, goldens: &[&str]) {
av1_parity_run_against(
units,
order,
goldens,
AV1_UNIT_COUNT,
AV1_DECODED_COUNT,
AV1_SHOWN_COUNT,
"AV1",
);
}
/// [`av1_parity_run`] with its stream's own counts, for the leg that does not
/// decode the vendored vector.
///
/// The three counts are three parameters, never derived from one another: the
/// vendored vector is 250 units / 274 decoded / 250 shown, and our host's stream is
/// 60 / 60 / 60. A harness that computed "hidden = 0" or "decoded = units" from
/// either would silently stop checking the other.
fn av1_parity_run_against(
units: &[&[u8]],
order: &Order,
goldens: &[&str],
unit_count: usize,
decoded_count: usize,
shown_count: usize,
label: &str,
) {
assert_eq!(
units.len(),
AV1_UNIT_COUNT,
"the IVF reader disagrees with the vector's temporal-unit count"
unit_count,
"{label}: the IVF reader disagrees with the stream's temporal-unit count"
);
assert_eq!(order.decode.len(), AV1_DECODED_COUNT);
assert_eq!(order.decode.len(), decoded_count);
assert_eq!(order.per_unit.len(), units.len());
assert_eq!(order.display.len(), goldens.len());
let luid = pinned_adapter();
let mut decoder = NativeD3d11Decoder::new(Codec::Av1, StreamFormat::SDR_420_8, luid, false)
.unwrap_or_else(|e| panic!("AV1: the box must host AV1 Profile 0 — {e:#}"));
.unwrap_or_else(|e| panic!("{label}: the box must host AV1 Profile 0 — {e:#}"));
let mut readback = Readback {
ctx: decoder.context.clone(),
staging: None,
@@ -2183,20 +2485,23 @@ mod parity {
decoded += 1;
}
}
assert_eq!(decoded, AV1_DECODED_COUNT);
assert_eq!(decoded, decoded_count);
assert_eq!(
presented, AV1_SHOWN_COUNT,
"every unit of this vector shows exactly one frame, so the production \
path must have handed back {AV1_SHOWN_COUNT} pictures"
presented, shown_count,
"{label}: every unit of this stream shows exactly one frame, so the \
production path must have handed back {shown_count} pictures"
);
let hidden = AV1_DECODED_COUNT - presented;
let hidden = decoded_count - presented;
assert_eq!(
hidden,
AV1_DECODED_COUNT - AV1_SHOWN_COUNT,
"the rung must have decoded 24 frames it never handed back — this counts \
what `decode_av1` RETURNED against what it decoded, so at zero the \
`!sub.show` suppression is not working (or this vector stopped hiding \
frames, which `the_av1_vector_hides_frames` would catch first)"
decoded_count - shown_count,
"{label}: the rung must have decoded {} frames it never handed back — this \
counts what `decode_av1` RETURNED against what it decoded, so a mismatch \
on the vendored vector means the `!sub.show` suppression is not working \
(or it stopped hiding frames, which `the_av1_vector_hides_frames` would \
catch first). On a stream with no hidden frames both sides are zero and \
this is a tautology deliberately, so one harness serves both shapes",
decoded_count - shown_count
);
let mut mismatches = 0usize;
@@ -2206,7 +2511,7 @@ mod parity {
.unwrap_or_else(|| panic!("display frame {n} names PicId {id}, never decoded"));
if got != golden {
if mismatches < 10 {
eprintln!("AV1: display frame {n} (PicId {id}): {got} != {golden}");
eprintln!("{label}: display frame {n} (PicId {id}): {got} != {golden}");
}
mismatches += 1;
}
@@ -2214,27 +2519,223 @@ mod parity {
assert_eq!(
mismatches,
0,
"AV1: {mismatches}/{} frames diverge from libavcodec (first 10 above; frame \
"{label}: {mismatches}/{} frames diverge from libavcodec (first 10 above; frame \
0 is a key frame if IT mismatches suspect the readback geometry \
(pitch/crop/plane offset) or the tile records rather than the reference \
handling)",
goldens.len()
);
eprintln!(
"AV1: {} delivered frames bit-identical to libavcodec, {hidden} hidden frames \
decoded and withheld",
"{label}: {} delivered frames bit-identical to libavcodec, {hidden} hidden \
frames decoded and withheld",
goldens.len()
);
}
/// The AV1 leg's post-mortem: one line per DISPLAY frame, its verdict against the
/// goldens beside the plan facts that could explain it.
///
/// Not a gate — it asserts nothing and always "passes". It exists because
/// [`av1_every_delivered_frame_hashes_bit_identical_to_libavcodec`] FAILED on both
/// GPUs of `.221` the first time it was ever run, and a count of diverging frames
/// is not a lead. This is what turned that count into one, on 2026-08-07:
///
/// * **NVIDIA RTX 3500 Ada** — display frames 0..=63 bit-identical, then every one
/// of the remaining 186 diverged. The first bad frame was the one whose
/// `order_hint` first reaches **64**, and its error was 174 luma pixels in a
/// single 16x24 block (max |delta| 8, chroma untouched) which then propagated
/// through prediction. The stream keeps the key frame (`order_hint` 0) in the
/// BWDREF and ALTREF2 slots for its whole length, so 64 is where the distance to
/// it reaches the edge of what `get_relative_dist` can represent at
/// `OrderHintBits = 7`.
/// * **Intel Arc** — only display frames 0, 1, 2, 3 and 10 were bit-identical, and
/// the divergence was STRUCTURAL rather than marginal (47% of luma at the first
/// bad frame, max |delta| 242, chroma wrong too): a frame predicted from the
/// wrong picture, not a filter rounding.
///
/// Both were deterministic — three runs each, identical first-divergent frame and
/// identical hashes — so neither was a race against the decode queue.
///
/// **⚠ Both were ONE defect, and the two unlike signatures argued for two.** The
/// submission named a single surface as `CurrPicTextureIndex` and as a
/// `RefFrameMapTextureIndex` entry on 268 of the vector's 274 frames — decode into
/// the picture you predict from — because [`pf_dxvadec::plan_to_dxva_av1`] released
/// the displaced reference before assigning the decode target its slot. Intel
/// followed the aliased surface immediately; NVIDIA tolerated it until the order-hint
/// wrap put one block's prediction on the far side of it. Fixing that one thing took
/// BOTH vendors to 250/250. Two readings this map invited and that were wrong:
/// "`primary_ref_frame` or its resolution" (Intel's one correct late frame is
/// PRIMARY_REF_NONE **because** it is the intra frame, which names no reference and
/// so cannot alias) and "motion-field projection at the `get_relative_dist` sign
/// flip" (the wrap is where an already-aliased surface first mattered on NVIDIA, not
/// what was wrong). Read a signature as evidence about WHERE, not about WHAT.
///
/// Set `PF_AV1_DUMP=<tag>` to also write a few frames' raw NV12 to the temp
/// directory. That is how "how badly" was answered: at a frame where ONE vendor
/// hashes correctly, that vendor's bytes are libavcodec's bytes and so a valid
/// reference for the other's, and `ffmpeg -f rawvideo -pix_fmt nv12` regenerates
/// the rest (the golden file's header carries the exact command).
#[test]
#[ignore = "diagnostic, needs a Windows D3D11 video device (see module docs)"]
fn av1_divergence_map() {
let units = split_ivf(TEST_25FPS_AV1);
let order = order_av1(&units, DISPLAY_AV1);
let goldens = golden_hashes(GOLDENS_AV1);
// Plan facts per PicId, from a planner run alongside the decoder's own.
let mut facts: HashMap<u64, String> = HashMap::new();
let mut hidden: std::collections::HashSet<u64> = std::collections::HashSet::new();
{
let mut planner = pf_dxvadec::Av1Planner::new();
for unit in &units {
for plan in planner.plan_au(unit).expect("the clean vector plans") {
let Some(id) = plan.dpb.stored else { continue };
let h = &*plan.header;
if !h.show_frame {
hidden.insert(id);
}
let mut refs = String::new();
for r in plan.refs.iter() {
match r {
Some(r) => {
refs.push_str(&format!("{}/{} ", r.slot, r.id));
}
None => refs.push_str("-/- "),
}
}
facts.insert(
id,
format!(
"ft={} show={} oh={:3} pri={} refresh={:#06x} grain={} seg={} \
sr={} warp={} refmvs={} skip={} refsel={} tiles={}x{} \
lf={:?} lfsharp={} lfdelta={}{} refd={:?} moded={:?} \
cdefbits={} lr={:?} refs=[{}]",
h.frame_type as u8,
u8::from(h.show_frame),
h.order_hint,
h.primary_ref_frame,
h.refresh_frame_flags,
u8::from(h.film_grain_params.apply_grain),
u8::from(h.segmentation_params.segmentation_enabled),
u8::from(h.use_superres),
u8::from(h.allow_warped_motion),
u8::from(h.use_ref_frame_mvs),
u8::from(h.skip_mode_present),
u8::from(h.reference_select),
h.tile_info.tile_cols,
h.tile_info.tile_rows,
h.loop_filter_params.loop_filter_level,
h.loop_filter_params.loop_filter_sharpness,
u8::from(h.loop_filter_params.loop_filter_delta_enabled),
u8::from(h.loop_filter_params.loop_filter_delta_update),
h.loop_filter_params.loop_filter_ref_deltas,
h.loop_filter_params.loop_filter_mode_deltas,
h.cdef_params.cdef_bits,
h.loop_restoration_params.frame_restoration_type,
refs.trim_end(),
),
);
}
}
}
let luid = pinned_adapter();
let mut decoder = NativeD3d11Decoder::new(Codec::Av1, StreamFormat::SDR_420_8, luid, false)
.expect("the box must host AV1 Profile 0");
let mut readback = Readback {
ctx: decoder.context.clone(),
staging: None,
};
// Raw NV12 for a few display frames is kept as well as its hash, so a
// divergence can be classified by plane and magnitude against a vendor whose
// hash at that same frame MATCHES the golden. It has to be captured inside
// the loop: surfaces are recycled, so by the end of the run the slot that
// held an early picture holds someone else's pixels.
let dump_tag = std::env::var("PF_AV1_DUMP").ok();
let wanted: Vec<u64> = if dump_tag.is_some() {
[3usize, 4, 10, 63, 64]
.iter()
.filter_map(|&n| order.display.get(n).copied())
.collect()
} else {
Vec::new()
};
let mut by_id: HashMap<u64, String> = HashMap::new();
for (index, unit) in units.iter().enumerate() {
decoder.decode_av1(unit).expect("decode");
for &id in &order.per_unit[index] {
let (slot, f, pool) = {
let session = decoder.session.as_ref().expect("session");
let slot = session.slots.slot_of(id).expect("slot");
let f = session.held[usize::from(slot)].expect("facts");
(slot, f, session.pool.clone())
};
let bytes =
readback.read(&decoder.device, &pool, u32::from(slot), (f.width, f.height));
if wanted.contains(&id) {
let tag = dump_tag.as_deref().unwrap_or("x");
let path = std::env::temp_dir().join(format!("pf-nv12-{tag}-pic{id}.bin"));
std::fs::write(&path, &bytes).expect("write the dump");
eprintln!("dumped pic {id} -> {}", path.display());
}
by_id.insert(id, sha256_hex(&bytes));
}
}
eprintln!("=== MAP BEGIN ===");
for (n, (id, golden)) in order.display.iter().zip(goldens.iter()).enumerate() {
let got = by_id.get(id).expect("decoded");
eprintln!(
"disp {n:3} pic {id:3} {} | {}",
if got == golden { "OK " } else { "BAD" },
facts.get(id).map(String::as_str).unwrap_or("?")
);
}
eprintln!("=== HIDDEN ===");
let mut h: Vec<u64> = hidden.into_iter().collect();
h.sort_unstable();
for id in h {
eprintln!(
"hidden pic {id:3} | {}",
facts.get(&id).map(String::as_str).unwrap_or("?")
);
}
eprintln!("=== MAP END ===");
}
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn av1_every_delivered_frame_hashes_bit_identical_to_libavcodec() {
let units = split_ivf(TEST_25FPS_AV1);
let order = order_av1(&units);
let order = order_av1(&units, DISPLAY_AV1);
av1_parity_run(&units, &order, &golden_hashes(GOLDENS_AV1));
}
/// **Our own host's AV1, at the only resolution where it emits more than one tile.**
///
/// The leg above runs a vector whose every frame is `tile_cols = tile_rows = 1`, so
/// every tile field `plan_to_dxva_av1` fills is the degenerate case. This stream is
/// `tile_rows = 2` on all 60 frames with both tiles in one Tile Group OBU, which is
/// the 4K split-encode shape the host actually ships — and it is 4K, so the
/// readback moves 12.4 MB per frame rather than 115 KB. See [`LOWDELAY_AV1`] for
/// what it does and does not cover; the short version is that it is a file, and the
/// last AV1 truncation lived somewhere a file cannot reach.
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
let units = split_ivf(LOWDELAY_AV1);
let order = order_av1(&units, DISPLAY_LOWDELAY_AV1);
av1_parity_run_against(
&units,
&order,
&golden_hashes(GOLDENS_LOWDELAY_AV1),
LOWDELAY_AV1_UNIT_COUNT,
LOWDELAY_AV1_DECODED_COUNT,
LOWDELAY_AV1_SHOWN_COUNT,
"AV1 (low-delay host stream, 4K two-tile)",
);
}
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn h264_every_frame_hashes_bit_identical_to_libavcodec() {
@@ -2251,6 +2752,32 @@ mod parity {
);
}
/// The leg that would have caught this rung's H.264 defect, and the only one that
/// could: **our own host's output** rather than a conformance vector.
///
/// `h264_every_frame_hashes_bit_identical_to_libavcodec` above passed 250/250 on an
/// RTX 4090, an AMD iGPU, an RTX 3500 Ada and an Intel Arc while this rung was
/// naming one surface as both `CurrPic` and a `RefFrameList` entry on 99% of the
/// access units of every stream punktfunk actually streams. The vector cannot reach
/// the shape — see [`LOWDELAY_H264`] — so no amount of running it harder would have
/// found this. That is the lesson worth keeping: a conformance vector proves
/// conformance to ITSELF, and the encoder we ship behind is a different stream.
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() {
let aus = split_h264_aus(LOWDELAY_H264);
let order = order_h264(&aus);
parity_run(
Codec::H264,
StreamFormat::SDR_420_8,
&aus,
&order,
&golden_hashes(GOLDENS_LOWDELAY),
LOWDELAY_FRAME_COUNT,
"H.264 (low-delay host stream)",
);
}
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn h265_every_frame_hashes_bit_identical_to_libavcodec() {
@@ -2267,6 +2794,32 @@ mod parity {
);
}
/// The HEVC twin of the low-delay H.264 leg — and the one that keeps HEVC's
/// exemption from the release-ordering defect a standing hardware fact.
///
/// `h265_every_frame_hashes_bit_identical_to_libavcodec` decodes a vector that
/// REORDERS, so it never puts an RPS drop and the eviction it causes in one access
/// unit and cannot see this class at all. This stream does, on 115 of its 120
/// access units — see [`LOWDELAY_H265`]. If a refactor ever moved `H265Planner`'s
/// snapshot ahead of `decode_rps` (where the other two planners take theirs), this
/// rung would name one surface as both `CurrPic` and a `RefPicList` entry on all
/// 115, and this leg is what would say so in pixels.
#[test]
#[ignore = "needs a Windows D3D11 video device (see module docs)"]
fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() {
let aus = split_h265_aus(LOWDELAY_H265);
let order = order_h265(&aus);
parity_run(
Codec::H265,
StreamFormat::SDR_420_8,
&aus,
&order,
&golden_hashes(GOLDENS_LOWDELAY_H265),
LOWDELAY_H265_FRAME_COUNT,
"H.265 (low-delay host stream)",
);
}
/// The ten-bit path, which no golden set in this program covered until now.
///
/// The HDR legs proved a Main10 session BUILDS and streams clean, which is a
@@ -2368,7 +2921,7 @@ mod parity {
fn the_ivf_reader_agrees_with_the_planner_and_the_av1_goldens() {
let units = split_ivf(TEST_25FPS_AV1);
assert_eq!(units.len(), AV1_UNIT_COUNT, "AV1 temporal units");
let order = order_av1(&units);
let order = order_av1(&units, DISPLAY_AV1);
assert_eq!(
order.decode.len(),
AV1_DECODED_COUNT,
@@ -45,6 +45,46 @@
//! no equivalent here. rav1d gets the machine's cores, and **at least two frame contexts**;
//! [`Av1Software::new`] carries the whole argument, because "at least two" is not a
//! performance choice but the difference between an error and `abort()`.
//!
//! # Why this rung is NOT process-isolated
//!
//! The frame-context floor closes the one abort we hit and can prove. It does not make the
//! rung panic-proof, and nothing at this call site can: rav1d exposes dav1d's C ABI, every
//! internal `rav1d_*` entry point is `pub(crate)`, so any reachable panic crosses
//! `extern "C"` as `panic_cannot_unwind` → `abort()`. No `catch_unwind`, no rung demotion
//! and no [`NoSoftwareRung`] refusal can contain it. Counted in rav1d 1.1.0's 60 source
//! files: 285 `unwrap()`, 214 `assert!`, 19 `unreachable!`, 11 `expect()`, 10 `panic!` —
//! 539 sites that are an `abort()` if a stream can reach them. #97 fixed ONE.
//!
//! Isolating the decoder in its own process is the only defence that actually works, and
//! it is deliberately NOT taken. The decision, so it is not re-litigated from scratch:
//!
//! * **The defect is a dependency's, and it is one line.** memorysafety/rav1d#1497 was
//! filed 2026-08-07 with the fix (`is_some_and` for the `unwrap`) and a reproducer.
//! Paying a permanent architectural tax to route around a bug that costs upstream one
//! line is the wrong trade while that line is still plausibly coming.
//! * **The residual risk is real but unquantified.** 539 panic sites is a scary number
//! and a meaningless one: not one of them is known to be reachable from a punktfunk
//! stream. The honest next step is to MEASURE reachability — fuzz this rung with
//! truncated, reordered and bit-flipped AUs and see whether any input aborts — not to
//! buy insurance against a number nobody has bounded. That is cheap; this is not.
//! * **The cost lands on the video path, and on three platforms.** pf-client-core builds
//! into the Linux, Windows and Android clients (the Apple clients decode through
//! VideoToolbox and never reach here). Each needs its own shared-memory transport for
//! `CpuPlanarFrame`s, its own child lifecycle, crash detection and restart, and its own
//! backpressure — and it adds a scheduling boundary to the rung that is ALREADY the
//! slowest one on the ladder. Zero-copy is a hard requirement here; an IPC hop that
//! copies frames would be rejected on its own terms.
//! * **What an abort actually costs is bounded.** This rung is reached because the GPU
//! rungs already failed, so the session is degraded before rav1d sees a byte. Losing
//! the process loses a session the user was going to have a bad time in regardless.
//! That is bad, and it is not the same as losing a working session.
//!
//! **Revisit when the calculus changes, which is a specific event, not a feeling:** a
//! SECOND distinct abort observed in the field, or a fuzzer finding a reachable panic.
//! Either turns this from one upstream bug into a class of them, and a class is what
//! justifies isolation. Until then the floor plus the upstream fix is the proportionate
//! answer, and the fuzzing is the work that would tell us we were wrong.
use crate::video::{CpuPlanarFrame, RungLoss};
use crate::video_color::ColorDesc;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -304,6 +304,7 @@ mod tests {
setup_slot: 0,
setup_id: 1,
setup_is_reference: true,
release_after_decode: Vec::new(),
refs: Vec::<DxvaRef>::new(),
mb_count,
}
+293 -34
View File
@@ -133,6 +133,60 @@ pub struct DecodePlanDxva {
/// surface exists for the decode itself plus any remaining DPB residency,
/// and may already have been released by this very AU's `removed`.
pub setup_is_reference: bool,
/// Surfaces this access unit's own end-of-picture bookkeeping retires while the
/// submission still NAMES them. Release them once the decode op is issued —
/// never inside the conversion, and never dropped.
///
/// # Why the conversion cannot release them
///
/// [`SlotMap::assign`] takes the LOWEST FREE slot. Release a picture here and the
/// setup assignment two lines later hands its surface straight back, so the
/// submission says `CurrPic = N` and `RefFrameList[k] = N` in one breath: the
/// picture decodes into a surface it predicts from. That is the AV1 defect of
/// 2026-08-07 ([`crate::pic_av1::DecodePlanDxvaAv1::release_after_decode`]) on this
/// codec, and on H.264 it is not exotic at all.
///
/// [`AuPlan::dpb_refs`] — which `RefFrameList` is built from — is snapshotted in
/// `H264Planner::begin_picture`, BEFORE `finish_picture` runs 8.2.5's marking and
/// C.4.5.3's bump. So a picture the sliding window unmarks and the bump then evicts
/// lands in both `dpb_refs` and `dpb.removed` for the same AU. It needs the two to
/// coincide, which needs the evicted picture to be already OUTPUT — and that is
/// precisely low-delay H.264: `max_num_reorder_frames = 0`, a picture output the
/// moment it decodes.
///
/// **Measured 2026-08-07, and it is the ordinary case, not a corner.** Every stream
/// a punktfunk host emits does it on 297 of 300 access units — 720p, 1080p and
/// 2160p alike, on both this rung and [`pf_vkdecode::pic::DecodePlanVk`]'s. NVENC
/// writes `max_num_ref_frames = 3` AND `max_dec_frame_buffering = 3`: the DPB is
/// exactly as deep as the reference count, so the window unmarks the oldest
/// reference in the very AU whose bump evicts it. The aliased picture is
/// `ref_idx 2` of a three-entry `num_ref_idx_l0_active` list — addressable by any
/// macroblock, not a spare the hardware could ignore.
///
/// `test-25fps.h264` measures ZERO and that is why this survived to here: it is
/// level 1.3 with no VUI `bitstream_restriction`, so its DPB is the level-derived 7
/// against 2 reference frames, and it REORDERS, which keeps an unmarked picture
/// alive past the AU that unmarked it. `data/lowdelay-640x480.h264` is vendored to
/// close exactly that gap.
///
/// # Why the caller can release them safely
///
/// The surfaces must outlive the CONVERSION, not the decode. One AU is planned,
/// converted and submitted before the next is planned, so once the decode op is
/// issued nothing can be assigned them before the next conversion — the same
/// argument the AV1 rung's deferral rests on.
///
/// # Deferring the whole `removed` list rather than a filtered part
///
/// Some removals are pictures no `RefFrameList` entry names (a non-reference
/// picture bumped long after it was unmarked), and those could be released here.
/// They are not, for three reasons: `refs` is built from the SLICE LISTS as well as
/// the snapshot, so a filter on `dpb_refs` would still miss a concealment
/// substitute the lists name; deferring costs nothing, because
/// [`SlotMap::new`]'s spare slot means `assign` always has a free slot while every
/// removal is still held (the DPB never exceeds `max_dpb_frames`, and the map holds
/// `max_dpb_frames + 1`); and one unconditional rule is a thing a reader can check.
pub release_after_decode: Vec<PicId>,
/// The marked DPB, resolved to surfaces — the AU's own references first, then
/// every other marked picture (module docs). Laid out in exactly this order in
/// `pic_params.RefFrameList`.
@@ -268,7 +322,10 @@ impl From<SlotError> for PlanToDxvaError {
/// 2. references resolve against the PRE-removal state (read-only) — this AU's
/// own end-of-picture marking can evict a picture its slices legitimately
/// reference;
/// 3. `removed` is applied, then the setup slot is assigned last.
/// 3. the setup slot is assigned last, and `removed` is NOT applied at all: it
/// leaves as [`DecodePlanDxva::release_after_decode`] for the caller to apply
/// once the decode op is issued. Applying it here would give the assignment
/// back a surface this submission still names — see that field's docs.
pub fn plan_to_dxva(
plan: &AuPlan,
slots: &mut SlotMap,
@@ -480,24 +537,29 @@ pub fn plan_to_dxva(
let slice_ranges: Vec<Range<usize>> = plan.slices.iter().map(|s| s.data.clone()).collect();
// Mutations LAST, after every fallible step above (fn docs). Removals first —
// they were real regardless of this AU's fate — then the setup assignment.
// Mutations LAST, after every fallible step above (fn docs).
//
// The removals are NOT applied here — they are handed back as
// `release_after_decode` for the caller to apply once the decode op is issued,
// because releasing them now would return their surfaces to the setup
// assignment below and alias `CurrPic` with a `RefFrameList` entry. The field's
// docs carry the measurement; this is the ordinary case on every stream a
// punktfunk host emits.
//
// The AU's own picture can itself appear in `removed`: a non-reference
// picture with no free frame buffer bypasses the DPB and is stored-and-
// evicted within one plan. Its surface must still exist for the decode
// itself, so it is assigned here and released right after.
// itself, so it is assigned here and released right after — the one removal
// that cannot be deferred, since deferring it would hand the caller the
// surface being decoded into.
let setup_evicted = plan.dpb.removed.contains(&setup_id);
for &id in &plan.dpb.removed {
if id == setup_id {
continue;
}
if !slots.release(id) {
// Tolerated but never silent: reachable only when the caller skipped
// feeding an AU's plan through this map.
trace!(id, "DpbUpdate removed an id this SlotMap never assigned");
}
}
let release_after_decode: Vec<PicId> = plan
.dpb
.removed
.iter()
.copied()
.filter(|id| *id != setup_id)
.collect();
let setup_slot = slots.assign(setup_id)?;
if setup_evicted {
slots.release(setup_id);
@@ -523,6 +585,7 @@ pub fn plan_to_dxva(
setup_slot,
setup_id,
setup_is_reference: pic.is_reference,
release_after_decode,
refs,
mb_count: width_mbs * height_mbs,
})
@@ -570,6 +633,17 @@ mod tests {
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
);
/// **Our own host's output**, and the only stream in this repository that reaches
/// the shape `release_after_decode` exists for: low-delay IPPP, 120 pictures,
/// `max_num_reorder_frames = 0`, and a DPB exactly as deep as its reference count.
///
/// Vendored beside the goldens the GPU legs decode it against (that file's header
/// carries the `punktfunk-host spike` command and the ffmpeg cross-check), because
/// three crates need it: this one for the CPU proof, `pf-vkdecode`'s `gpu_parity`
/// and `pf-client-core`'s `video_d3d11_native::parity` for the hardware one.
const LOWDELAY_640X480: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264");
/// Test-only AU splitter, the same shape pf-vkdecode's `pic` tests use
/// (which in turn mirrors pf-bitstream's `#[cfg(test)]`-private helper): a
/// new AU starts at a non-slice NALU following a slice, or at a slice whose
@@ -602,10 +676,22 @@ mod tests {
/// Plan the vendored stream and convert every AU, returning the plans paired
/// with their conversions.
fn convert_stream() -> Vec<(AuPlan, DecodePlanDxva)> {
convert(TEST_25FPS)
}
/// The same over [`LOWDELAY_640X480`].
fn convert_low_delay() -> Vec<(AuPlan, DecodePlanDxva)> {
convert(LOWDELAY_640X480)
}
/// Plan and convert a whole stream the way a caller does — including applying
/// `release_after_decode` once the (notional) decode op is issued, which is what
/// keeps the ledger from filling up over 120 access units.
fn convert(stream: &[u8]) -> Vec<(AuPlan, DecodePlanDxva)> {
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
let mut out = Vec::new();
for (i, au) in split_into_aus(TEST_25FPS).into_iter().enumerate() {
for (i, au) in split_into_aus(stream).into_iter().enumerate() {
let Ok(plan) = planner.plan_au(au) else {
continue;
};
@@ -614,6 +700,9 @@ mod tests {
*map = SlotMap::new(plan.picture.max_dpb_frames);
}
let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion");
for &id in &dxva.release_after_decode {
assert!(map.release(id), "AU {i}: deferred id {id} held no slot");
}
out.push((plan, dxva));
}
out
@@ -881,6 +970,156 @@ mod tests {
assert_eq!(converted.len(), 250);
}
/// The hazard the vendored vector CANNOT see, on a stream that can.
///
/// `removed ∩ dpb_refs` is the aliasing precondition: a picture this AU's own
/// end-of-picture bookkeeping retires while `RefFrameList` still names it. Release
/// it inside the conversion and [`SlotMap::assign`] hands its surface straight back
/// to `CurrPic`, so the picture decodes into one it predicts from.
///
/// On `test-25fps.h264` the intersection is **zero**, and for two independent
/// reasons that both happen to be properties of that vector rather than of H.264:
/// it is level 1.3 with no VUI `bitstream_restriction`, so `dpb_limit` falls back to
/// A.3.1's level ceiling and gives a 7-frame DPB against `max_num_ref_frames = 2`
/// (the sliding window unmarks two AUs before the bump can evict); and it REORDERS,
/// which keeps an unmarked picture alive for output past the AU that unmarked it.
/// That zero is what let the eager release survive two milestones.
///
/// On `lowdelay-640x480.h264` — OUR host's output, vendored for exactly this — it is
/// **117 of 120 access units**, measured the same way at 720p, 1080p and 2160p. The
/// difference is the encoder, not the resolution: NVENC writes
/// `max_num_ref_frames = 3` AND `max_dec_frame_buffering = 3`, a DPB exactly as deep
/// as the reference count, so 8.2.5's window unmarks the oldest reference in the very
/// AU whose C.4.5.3 bump evicts it — and `max_num_reorder_frames = 0` means it has
/// already been output, which is what makes it evictable at all.
///
/// So this is not a tripwire any more: it pins BOTH numbers, and the second one is
/// what makes `release_after_decode` a fixed defect rather than a precaution.
///
/// HEVC needs no such test: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`
/// has updated the DPB, so an RPS-dropped picture is structurally never in the
/// snapshot `RefPicList` is built from — and a low-delay HEVC stream from the same
/// host measures 0 of 300, which is the argument confirmed rather than assumed.
#[test]
fn the_low_delay_stream_removes_pictures_its_own_reference_list_names_and_the_vector_never_does(
) {
fn intersections(stream: &[u8]) -> (usize, usize, usize) {
let mut planner = H264Planner::new();
let (mut both, mut with_removals, mut planned) = (0usize, 0usize, 0usize);
for au in split_into_aus(stream) {
let Ok(plan) = planner.plan_au(au) else {
continue;
};
planned += 1;
if !plan.dpb.removed.is_empty() {
with_removals += 1;
}
both += plan
.dpb
.removed
.iter()
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
.count();
}
(planned, with_removals, both)
}
let (planned, with_removals, both) = intersections(TEST_25FPS);
assert_eq!(planned, 250);
assert!(
with_removals > 0,
"no access unit of the vendored vector removed anything, so the zero below \
would be empty for a reason that has nothing to do with the hazard"
);
assert_eq!(
both, 0,
"the vendored vector is supposed to be BLIND to this shape — a non-zero \
here means the reordering/DPB-depth reasoning above is wrong, and the \
low-delay numbers below need re-deriving before they mean anything"
);
let (planned, with_removals, both) = intersections(LOWDELAY_640X480);
assert_eq!(planned, 120);
assert_eq!(with_removals, 117);
assert_eq!(
both, 117,
"the low-delay stream must still exercise the aliasing precondition on \
nearly every access unit if this ever drops to zero the deferral below \
is no longer being TESTED by anything, whatever else still passes"
);
}
/// The fix itself: no submission names its decode surface as a reference.
///
/// [`the_setup_surface_is_the_current_picture_entry_and_is_never_also_a_reference_entry`]
/// asserts this over the vendored vector, where it held even before
/// `release_after_decode` existed. This is the same invariant over the stream that
/// BREAKS it — 117 of 120 access units before the deferral, every one of them
/// decoding into a surface it predicts from.
#[test]
fn the_low_delay_stream_never_aliases_its_decode_surface_with_a_reference() {
let converted = convert_low_delay();
assert_eq!(converted.len(), 120);
let mut deferred_total = 0usize;
for (i, (_, dxva)) in converted.iter().enumerate() {
assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot);
deferred_total += dxva.release_after_decode.len();
for r in &dxva.refs {
assert_ne!(
r.slot, dxva.setup_slot,
"AU {i}: reference picture {} shares surface {} with the decode \
target the deferral is not holding",
r.id, r.slot
);
}
}
assert_eq!(
deferred_total, 117,
"every access unit that removes a picture must defer it; a zero here with \
the assertions above still passing would mean the stream stopped \
exercising the shape"
);
}
/// The deferral costs no slot the map does not have.
///
/// Holding every removal through the setup assignment is only free because
/// [`SlotMap::new`] allocates `max_dpb_frames + 1` and the DPB never exceeds
/// `max_dpb_frames` — so a free slot always exists even with the whole `removed`
/// list still held. Measured rather than argued: the deepest the ledger ever gets
/// on the stream that defers on 117 of 120 access units.
#[test]
fn deferring_every_removal_still_fits_the_ledger() {
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
let mut peak = 0usize;
let mut capacity = 0usize;
for (i, au) in split_into_aus(LOWDELAY_640X480).into_iter().enumerate() {
let Ok(plan) = planner.plan_au(au) else {
continue;
};
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
if map.capacity() != plan.picture.max_dpb_frames + 1 {
*map = SlotMap::new(plan.picture.max_dpb_frames);
}
let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion");
// The peak is measured BEFORE the deferred releases are applied: that is
// the moment the map is fullest, and the moment `assign` had to find a
// free slot in.
peak = peak.max(map.held().count());
capacity = map.capacity();
for &id in &dxva.release_after_decode {
assert!(map.release(id), "AU {i}: deferred id {id} held no slot");
}
}
assert_eq!(capacity, 4, "max_dec_frame_buffering 3 + the spare slot");
assert_eq!(
peak, 4,
"the deferral is expected to USE the spare slot — a peak of 3 would mean \
the removals are being released early after all"
);
}
#[test]
fn the_setup_surface_is_the_current_picture_entry_and_is_never_also_a_reference_entry() {
for (_, dxva) in convert_stream() {
@@ -1101,27 +1340,47 @@ mod tests {
assert_eq!(&bytes[10..14], &40u32.to_le_bytes());
}
/// The whole-stream churn check: no two live pictures may share a surface index,
/// which for DXVA is the difference between a decode and a corrupted reference.
///
/// Run over BOTH streams, because they stress opposite halves of the ledger: the
/// vendored vector has a DPB (7) far deeper than its reference count (2) and so
/// never has to reuse a surface promptly, while the low-delay stream's DPB is
/// exactly its reference count and cycles all four slots every four pictures.
///
/// The loop applies `release_after_decode` because the CALLER does; a loop that
/// drops it holds a surface per access unit and dies of `SlotError::Full` — which
/// is what this test did the moment the deferral landed, and is the cheapest
/// possible demonstration that the deferral is real rather than decorative.
#[test]
fn a_slot_is_reused_only_after_its_picture_leaves_the_dpb() {
// The whole-stream churn check: no two live pictures may share a surface
// index, which for DXVA is the difference between a decode and a
// corrupted reference.
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
let mut live: Vec<(PicId, u8)> = Vec::new();
for (i, au) in split_into_aus(TEST_25FPS).into_iter().enumerate() {
let Ok(plan) = planner.plan_au(au) else {
continue;
};
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
let removed = plan.dpb.removed.clone();
let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion");
live.retain(|&(id, _)| !removed.contains(&id));
assert!(
live.iter().all(|&(_, slot)| slot != dxva.setup_slot),
"AU {i} decodes into a surface a live picture still holds"
);
live.push((dxva.setup_id, dxva.setup_slot));
for (label, stream) in [("vendored", TEST_25FPS), ("low-delay", LOWDELAY_640X480)] {
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
let mut live: Vec<(PicId, u8)> = Vec::new();
for (i, au) in split_into_aus(stream).into_iter().enumerate() {
let Ok(plan) = planner.plan_au(au) else {
continue;
};
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
let removed = plan.dpb.removed.clone();
let dxva = plan_to_dxva(&plan, map, i as u32 + 1).expect("conversion");
// The check runs BEFORE the deferred releases: the aliasing this
// guards against is a property of the SUBMISSION, and at submission
// time every removed picture is still live by construction.
assert!(
live.iter().all(|&(_, slot)| slot != dxva.setup_slot),
"{label} AU {i} decodes into a surface a live picture still holds"
);
for &id in &dxva.release_after_decode {
assert!(
map.release(id),
"{label} AU {i}: deferred id {id} held no slot"
);
}
live.retain(|&(id, _)| !removed.contains(&id));
live.push((dxva.setup_id, dxva.setup_slot));
}
}
}
}
+357 -14
View File
@@ -122,6 +122,47 @@ pub struct DecodePlanDxvaAv1 {
pub bitstream: Av1Bitstream,
pub setup_slot: u8,
pub setup_id: PicId,
/// Pictures this frame's own `refresh_frame_flags` displaces from the store
/// while THIS submission still NAMES them — their surfaces may not be recycled
/// until the decode op has been issued, and the caller owes exactly that.
///
/// AV1 applies `refresh_frame_flags` AFTER the frame is decoded (7.20), so
/// `ref_frame_idx` resolves against the store as it stood BEFORE this frame and
/// a frame that reads a slot it then overwrites is the ORDINARY case, not an
/// exotic one: **268 of the vendored vector's 274 frames** do it, first at frame
/// 6. Releasing such a picture inside this conversion — which is what the H.264
/// and H.265 siblings do with their whole `removed` list, and what this one did
/// until the parity harness caught it — hands its surface straight back to
/// [`Self::setup_slot`], because [`SlotMap::assign`] takes the lowest free slot
/// and the lowest free slot is the one just vacated. The submission then says
/// `CurrPicTextureIndex = N` and `RefFrameMapTextureIndex[k] = N` in the same
/// breath: decode into the surface you are predicting from.
///
/// Neither vendored H.264 nor H.265 vector ever produces that shape (measured:
/// zero on the 250-AU clips), which is why the eager release survived two
/// codecs believed hardware-proven and opened on the first AV1 frame past the key
/// frame's neighbourhood.
///
/// ⚠ That zero turned out to be a fact about the VECTORS. H.264 has the identical
/// defect on any low-delay stream — 117 of 120 access units of our own host's
/// output, wrong pixels on three GPUs — and now carries the identical deferral
/// ([`crate::pic::DecodePlanDxva::release_after_decode`], which records the
/// measurement). H.265 is the only one of the three that is genuinely safe, and
/// structurally: its planner snapshots `dpb_refs` after `decode_rps`. The Vulkan rung
/// carries the same contract for the same reason
/// (`pf_vkdecode::pic_av1::DecodePlanVkAv1::release_after_decode`), and this
/// rung's constraint is the STRICTER of the two: Vulkan binds only the references
/// the frame names, while `RefFrameMapTextureIndex` declares the whole store, so
/// every picture the store still names has to survive — not just the seven the
/// frame reads.
///
/// This is exactly `dpb.removed` less the picture being stored, and that is not a
/// coincidence to be tidied into a filter: the planner snapshots `dpb_refs` before
/// any mutation, so every removal is by construction a picture the store named
/// (see the conversion's own comment). Applying the list completes the plan's
/// bookkeeping and never invents a removal. A caller that drops it holds a surface
/// on nearly every frame and runs the ledger dry within ten.
pub release_after_decode: Vec<PicId>,
}
/// Why a plan cannot be expressed as DXVA AV1 buffers.
@@ -629,12 +670,32 @@ pub fn plan_to_dxva_av1(
}
// --- mutations, after every fallible step -----------------------------
for &id in &plan.dpb.removed {
if id == setup_id {
continue;
}
let _ = slots.release(id);
}
// ⚠ EVERY removed picture is held back, and nothing is released here at all.
//
// A picture this submission NAMES may be displaced by this same frame's refresh;
// its surface is still in `ref_frame_map` above, so releasing it would hand that
// very surface to `setup_slot` below and the frame would decode into a picture it
// predicts from. What makes the rule "every removal" rather than "the removals
// the store names" is a property of the PLANNER: `Av1Planner::plan_frame`
// snapshots `dpb_refs` before any mutation, and `refresh_slots` can only report a
// picture that was in `self.slots` at that moment, so `dpb.removed` is always a
// SUBSET of the store `ref_frame_map` was built from. Filtering on `dpb_refs`
// here would be a condition that is never false wearing the clothes of a
// decision; the subset relation is asserted in
// `the_decode_target_never_aliases_a_surface_the_submission_names` instead, where
// a planner change that broke it fails loudly.
//
// `setup_id` is excluded as a safety property rather than as a live case: a
// picture this frame stores cannot also be one its own refresh displaced, because
// `refresh_slots` retains out any displaced id still held anywhere. Releasing it
// would return the surface being decoded into.
let release_after_decode: Vec<PicId> = plan
.dpb
.removed
.iter()
.copied()
.filter(|id| *id != setup_id)
.collect();
let setup_slot = match slots.slot_of(setup_id) {
Some(existing) => existing,
None => slots.assign(setup_id)?,
@@ -642,6 +703,20 @@ pub fn plan_to_dxva_av1(
let color = &seq.color_config;
let mut pic_params = PicParamsAv1::zeroed();
// ⚠ UPSCALED width, where libavcodec sends the CODED one — a divergence that is
// inert on every stream that exists here and is written down rather than
// "fixed" because nothing can measure it.
//
// `dxva2_av1.c` sends `avctx->width`, and `update_context_with_frame_header`
// sets that from `frame_width_minus_1 + 1` — FrameWidth, the pre-superres coded
// width. The same goes for `frame_refs[i].width`, which libav reads off the
// reference's `AVFrame`. With superres OFF the two are equal by definition
// (7.20: `UpscaledWidth = FrameWidth` when `use_superres` is 0), which is every
// frame of both vendored vectors and every frame a punktfunk host emits — no
// encoder in this program codes superres. So the 250/250 parity result on two
// vendors says nothing either way about which is right, and changing it would
// be an unmeasured change to a rung that is finally proven. Revisit with a
// superres vector and a driver-by-driver measurement, not by reading.
pic_params.width = h.upscaled_width;
pic_params.height = h.frame_height;
pic_params.max_width = u32::from(seq.max_frame_width_minus_1) + 1;
@@ -758,6 +833,7 @@ pub fn plan_to_dxva_av1(
bitstream,
setup_slot,
setup_id,
release_after_decode,
})
}
@@ -781,6 +857,157 @@ mod tests {
"../../pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1"
);
/// **Our own host's AV1 at 4K**, vendored beside the goldens the GPU legs decode it
/// against (`lowdelay-3840x2160-av1.nv12.sha256` carries the `punktfunk-host spike`
/// command and the ffmpeg cross-check).
///
/// It is here for ONE property the vendored vector cannot supply: **two tiles**.
/// Every frame of `test-25fps.ivf.av1` is `tile_cols = tile_rows = 1`, so
/// [`the_tile_sizes_are_superblock_counts_not_the_coded_minus_one`] can only ever
/// read index 0 of the tile arrays and assert the rest are zero. This stream is
/// `tile_cols = 1, tile_rows = 2` on all 60 frames, with both tiles in a single
/// Tile Group OBU — the 4K split-encode shape the host ships.
const LOWDELAY_3840X2160_AV1: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-3840x2160.ivf.av1");
/// Convert one plan the way the RUNG must: the conversion, then the releases it
/// defers past the decode op ([`DecodePlanDxvaAv1::release_after_decode`]).
///
/// Not a convenience — it is the caller's half of the contract, and the same
/// helper the Vulkan rung's tests carry for the same reason. A loop that
/// converts without it holds a surface on 268 of this vector's 274 frames and
/// runs the nine-slot ledger dry inside ten.
fn convert(au: &[u8], plan: &AuPlan, slots: &mut SlotMap) -> DecodePlanDxvaAv1 {
let dx = plan_to_dxva_av1(au, plan, slots).expect("the clean vector converts");
for &id in &dx.release_after_decode {
assert!(
slots.release(id),
"a deferred release named picture {id}, which holds no surface"
);
}
dx
}
/// The decode target never shares a surface with a picture the submission names.
///
/// The defect this pins is the one the Windows parity harness caught and nothing
/// on the CPU could see. AV1 applies `refresh_frame_flags` AFTER the frame is
/// decoded (7.20), so a frame that reads a slot it then overwrites is ordinary —
/// **268 of this vector's 274 frames**, first at frame 6 — and releasing the
/// displaced picture inside the conversion handed its surface straight to
/// `setup_slot`, because [`SlotMap::assign`] takes the lowest free slot and the
/// lowest free slot is the one just vacated. The submission then said
/// `CurrPicTextureIndex = N` and `RefFrameMapTextureIndex[k] = N` at once.
///
/// Measured on hardware before the fix: Intel Arc decoded 245 of 250 delivered
/// frames wrong (47% of luma at the first bad frame, max |delta| 242, chroma
/// wrong too — a frame predicted from the wrong picture), and the only late
/// frame it got right was the one intra frame, which names no reference and so
/// could not alias. NVIDIA tolerated it.
///
/// The assertion is against the WHOLE STORE, not just the seven names this frame
/// reads: `RefFrameMapTextureIndex` declares every occupied slot, so a driver is
/// entitled to consult one the frame never names.
#[test]
fn the_decode_target_never_aliases_a_surface_the_submission_names() {
let mut planner = Av1Planner::new();
let mut slots = SlotMap::new(NUM_REF_SLOTS);
let (mut frames, mut deferring, mut deferred) = (0u32, 0u32, 0u32);
let mut peak = 0usize;
for packet in IvfIterator::new(AV1_25FPS) {
for plan in planner.plan_au(packet).expect("the clean vector plans") {
if plan.dpb.stored.is_none() {
continue;
}
// Deliberately NOT `convert` — this test applies the deferred
// releases itself, after checking each one.
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("the vector converts");
frames += 1;
peak = peak.max(slots.active());
// `#[repr(packed)]` — copy the fields out before reading them.
let curr = dx.pic_params.curr_pic_texture_index;
let store = dx.pic_params.ref_frame_map_texture_index;
assert!(
store.iter().all(|surface| *surface != curr),
"frame {frames}: surface {curr} is both CurrPicTextureIndex and a \
RefFrameMapTextureIndex entry the frame decodes into a picture \
it predicts from"
);
// ⚠ THE PROPERTY THE DEFERRAL RESTS ON, asserted about the PLANNER
// rather than about the conversion's own output.
//
// The conversion holds back every removal, which is only safe-and-
// sufficient because `Av1Planner` snapshots `dpb_refs` before any
// mutation and `refresh_slots` can only report a picture that was in
// it — so `dpb.removed` is a subset of the store `ref_frame_map` was
// built from. Asserting instead that each DEFERRED id is in
// `dpb_refs` would be vacuous: the deferred list is filtered out of
// `removed`, so that check compares an expression with itself. This
// one can fail, and if a planner change ever makes it fail the
// conversion is releasing a surface the submission points at.
for &id in &plan.dpb.removed {
assert!(
plan.dpb_refs.iter().any(|r| r.id == id),
"frame {frames}: the planner removed picture {id}, which the \
pre-decode store never held `ref_frame_map` is built from \
that store, so a removal outside it is a picture this \
conversion could release without aliasing, and the blanket \
deferral above stops being justified"
);
}
// Every deferred id is one this plan really removed, and it still
// holds the surface the caller is being asked to give back.
for &id in &dx.release_after_decode {
assert!(
plan.dpb.removed.contains(&id),
"frame {frames}: deferred picture {id} is not in this plan's \
removed list"
);
assert_ne!(
id, dx.setup_id,
"frame {frames}: the picture being decoded must never be \
deferred releasing it returns the surface being written"
);
assert!(
slots.slot_of(id).is_some(),
"frame {frames}: a deferred picture must still hold its surface"
);
}
if !dx.release_after_decode.is_empty() {
deferring += 1;
deferred += dx.release_after_decode.len() as u32;
}
for &id in &dx.release_after_decode {
assert!(slots.release(id));
}
}
}
assert_eq!(frames, 274);
assert_eq!(
deferring, 268,
"268 of 274 frames of this vector displace a picture their own submission \
names; at zero this test compares an empty list against itself and the \
deferral could be deleted without a single assertion noticing"
);
assert_eq!(deferred, 268, "one displaced picture per frame here");
// The nine-slot ledger is `NUM_REF_SLOTS + 1`, and holding a displaced
// picture one frame longer is exactly what that spare is for — the pool is
// allocated `SlotMap::capacity()` surfaces (`pf_dxvadec::pool_size`), so a
// peak above it would be a submission naming a surface that does not exist.
assert!(
peak <= slots.capacity(),
"peak {peak} surfaces held exceeds the {} the pool allocates",
slots.capacity()
);
eprintln!(
"frames {frames} · deferring {deferring} · peak surfaces held {peak}/{}",
slots.capacity()
);
}
/// The whole vector, converted **and packed** — the closest a CPU gate gets to
/// the hardware leg, and the test that would have caught the defect this
/// module shipped with.
@@ -815,7 +1042,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
// Poison the mapping so a record can only be "right" by pointing
// at bytes this pack actually wrote.
@@ -949,8 +1176,7 @@ mod tests {
index_by_surface_would_differ += 1;
}
}
let dx =
plan_to_dxva_av1(packet, &plan, &mut slots).expect("the clean vector converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
// Tile records must describe TILE PAYLOAD ranges inside the access
@@ -1153,7 +1379,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
let lf = &plan.header.loop_filter_params;
// `#[repr(packed)]` — copy the block out before reading its fields.
@@ -1244,7 +1470,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
let raw = &plan.header.cdef_params;
// `#[repr(packed)]` — copy the arrays out before indexing them.
@@ -1335,7 +1561,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
let t = &plan.header.tile_info;
// `#[repr(packed)]` — copy the block out before reading its arrays.
@@ -1375,6 +1601,123 @@ mod tests {
assert_eq!(frames, 274);
}
/// The same tile arrays with a SECOND tile in them — the case the vendored vector
/// cannot reach and this conversion had therefore never been run against.
///
/// [`the_tile_sizes_are_superblock_counts_not_the_coded_minus_one`] asserts index 0
/// is right and `1..` are zero, which is everything a one-tile vector can say. Both
/// halves of that are shapes a multi-tile bug would satisfy: a conversion that
/// wrote only tile 0 and left the rest zero would pass it on every frame of the
/// vector and hand the driver a frame with half its height missing here.
///
/// So this pins the row arrays with both entries live, that the second entry is the
/// SECOND tile's size rather than a repeat of the first, and that everything past
/// the grid is still zero. `tile_rows = 2` with `height_in_sbs_minus_1 = [16, 16]`
/// against a 2160-line frame is 17 + 17 = 34 superblocks of 64, i.e. 2176 lines —
/// the padded height, which is the arithmetic the one-tile test's `div_ceil` also
/// checks but cannot check twice.
#[test]
fn a_two_tile_frame_fills_both_row_entries_and_leaves_the_rest_zero() {
let mut planner = Av1Planner::new();
let mut slots = SlotMap::new(NUM_REF_SLOTS);
let mut frames = 0u32;
for packet in IvfIterator::new(LOWDELAY_3840X2160_AV1) {
for plan in planner
.plan_au(packet)
.expect("the low-delay 4K stream plans")
{
if plan.dpb.stored.is_none() {
continue;
}
let dx = convert(packet, &plan, &mut slots);
frames += 1;
let t = &plan.header.tile_info;
// `#[repr(packed)]` — copy the block out before reading its arrays.
let tiles = dx.pic_params.tiles;
let (widths, heights) = (tiles.widths, tiles.heights);
assert_eq!(
(tiles.cols, tiles.rows),
(1, 2),
"frame {frames}: this stream is vendored FOR its second tile row. \
One tile here means it was regenerated below 4K (1440p and down \
measured single-tile) and this test has quietly become a duplicate \
of the vendored vector's"
);
let sb = if plan.sequence.use_128x128_superblock {
128
} else {
64
};
assert_eq!(
(widths[0], 0u16),
(plan.header.frame_width.div_ceil(sb) as u16, 0u16),
"frame {frames}: the single tile COLUMN spans the whole width"
);
// Both row entries, each the coded value plus one — and read
// independently, so a conversion that broadcast entry 0 across the
// array would still have to get entry 1's own coded value right.
assert_eq!(
(heights[0], heights[1]),
(
t.height_in_sbs_minus_1[0] as u16 + 1,
t.height_in_sbs_minus_1[1] as u16 + 1
),
"frame {frames}: each tile row's height is its OWN \
`height_in_sbs_minus_1 + 1`"
);
assert_eq!(
u32::from(heights[0]) + u32::from(heights[1]),
plan.header.frame_height.div_ceil(sb),
"frame {frames}: the two tile rows must tile the frame exactly — a \
short second row is a frame with missing lines, which is precisely \
the shape the host once shipped over the wire"
);
// Past the grid the arrays stay zero: a driver reading `rows` entries
// never sees them, and a phantom entry is a tile the frame has not.
assert!(widths[1..].iter().all(|w| *w == 0));
assert!(heights[2..].iter().all(|h| *h == 0));
// TWO tile RECORDS from ONE tile group, with distinct rows and
// non-empty spans. `tile_cols * tile_rows` records is libavcodec's own
// count, and this stream is the only one here where it exceeds the
// number of tile GROUPS — so a conversion that emitted one record per
// group (the coarser shape the module docs warn against) is
// indistinguishable from a correct one on the vendored vector and
// fails here.
assert_eq!(
plan.tiles.len(),
1,
"frame {frames}: both tiles arrive in one Tile Group OBU"
);
assert_eq!(
dx.tiles.len(),
2,
"frame {frames}: one record per TILE, not per tile group"
);
assert_eq!(
(dx.tiles[0].row, dx.tiles[0].column),
(0, 0),
"frame {frames}: tile 0 is row 0"
);
assert_eq!(
(dx.tiles[1].row, dx.tiles[1].column),
(1, 0),
"frame {frames}: tile 1 is the SECOND ROW of a single column — a \
(0, 1) here means rows and columns are transposed, which one \
square tile grid could never show"
);
assert!(
dx.tiles.iter().all(|r| r.data_size > 0),
"frame {frames}: every tile record must span real bytes; a \
zero-length second record is the whole bottom half of the frame \
missing"
);
}
}
assert_eq!(frames, 60, "the low-delay 4K stream is 60 coded frames");
}
/// Three fields whose correct value is a SENTINEL or a constant, on every frame
/// of the vector — none of which any other assertion here would notice.
///
@@ -1398,7 +1741,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, &plan, &mut slots).expect("converts");
let dx = convert(packet, &plan, &mut slots);
frames += 1;
let pp = &dx.pic_params;
let status = pp.status_report_feedback_number;
@@ -1478,7 +1821,7 @@ mod tests {
if plan.dpb.stored.is_none() {
continue;
}
let dx = plan_to_dxva_av1(packet, plan, &mut slots).expect("converts");
let dx = convert(packet, plan, &mut slots);
decoded.push((dx.setup_id, dx.setup_slot));
}
if decoded.len() > 1 {
+257
View File
@@ -690,6 +690,18 @@ mod tests {
"../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/64x64-I-P-B-P.h265"
);
/// **Our own host's HEVC**, and the only stream in this repository that reaches
/// the DPB pressure HEVC's exemption is claimed against: low-delay IPPP, 120
/// pictures of 640x480, `sps_max_num_reorder_pics = 0`, and a five-picture DPB
/// against the four pictures 8.3.2 keeps marked.
///
/// Vendored beside the goldens the GPU legs decode it against (that file's header
/// carries the `punktfunk-host spike` command and the ffmpeg cross-check), the
/// same way `lowdelay-640x480.h264` is, and read from the same path by all three
/// crates that need it.
const LOWDELAY_640X480_H265: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h265");
/// Test-only AU splitter, mirroring pf-vkdecode's (which mirrors
/// pf-bitstream's `#[cfg(test)]`-private helper).
fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> {
@@ -916,8 +928,36 @@ mod tests {
}
}
/// HEVC's freedom from the aliasing that cost AV1 and H.264 a deferral, and the
/// PLANNER property that grants it.
///
/// Both other codecs release a removed picture's slot and then let
/// [`SlotMap::assign`] hand it straight back to the decode target, so `CurrPic`
/// and a `RefPicList` entry name one surface. This conversion still releases its
/// whole `removed` list inline, and is safe doing so for one reason: `H265Planner`
/// snapshots `dpb_refs` AFTER `decode_rps` has updated the DPB, so a picture this
/// AU's RPS dropped is never in the set `RefPicList` is built from, and nothing
/// later in the AU unmarks anything. `H264Planner` and `Av1Planner` both snapshot
/// BEFORE their marking, and both needed the deferral.
///
/// The second assertion is that argument made falsifiable. The first is only the
/// consequence, and on this vector the consequence would hold even if the argument
/// stopped being true — the vendored H.264 vector taught that lesson expensively
/// (it measured zero aliasing for two milestones while every stream we ship
/// aliased on 99% of its frames). Moving `dpb_snapshot()` above `decode_rps` would
/// leave the first assertion passing and break the second on the first AU whose
/// RPS drops a picture, which on this vector is most of them.
///
/// This test covers the VENDORED VECTOR only, which reorders and therefore cannot
/// reach the DPB pressure the exemption is really claimed against. The stream that
/// can is [`LOWDELAY_640X480_H265`], and it carries its own pair of tests below —
/// [`the_low_delay_stream_reaches_the_dpb_pressure_and_hevc_still_does_not_alias`]
/// and [`the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps`],
/// the second of which drives the alias through this very conversion.
#[test]
fn the_current_picture_is_named_by_curr_pic_and_never_aliases_a_reference() {
let mut aus_with_removals = 0usize;
let mut both = 0usize;
for (plan, dxva) in convert_stream(TEST_25FPS) {
assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot);
assert!(!dxva.pic_params.CurrPic.associated());
@@ -928,7 +968,224 @@ mod tests {
for r in &dxva.refs {
assert_ne!(r.slot, dxva.setup_slot, "a reference aliases the target");
}
if !plan.dpb.removed.is_empty() {
aus_with_removals += 1;
}
both += plan
.dpb
.removed
.iter()
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
.count();
}
assert!(
aus_with_removals > 0,
"no AU of this vector removed anything, so the zero below would be empty \
for a reason that has nothing to do with the property being asserted"
);
assert_eq!(
both, 0,
"{both} picture(s) are in an AU's own reference set AND removed by it. \
That is the H.264/AV1 aliasing precondition, and HEVC is supposed to be \
structurally incapable of it so the snapshot in `H265Planner` has moved \
ahead of `decode_rps`. Restore the ordering, or give this conversion the \
`release_after_decode` deferral the other two carry; do NOT relax this \
number"
);
}
/// The marked DPB as an access unit's `decode_rps` FINDS it — the set
/// `dpb_snapshot()` would return from the other side of that call.
///
/// Exact, not approximate. `H265Planner::begin_picture` runs `decode_rps` →
/// `update_dpb_before_decoding` → `dpb_snapshot`, and the only thing between AU
/// N-1's snapshot and AU N's `decode_rps` is `finish_picture(N-1)` storing its
/// picture marked "used for short-term reference". So the pre-RPS marked set is
/// exactly `dpb_refs(N-1) {stored(N-1)}` — no DPB replay needed, and no
/// dependence on the planner internals staying reachable from a test.
///
/// A sub-layer non-reference picture is stored but NOT marked, so it is excluded;
/// the callers assert their streams contain none, which keeps the reconstruction
/// honest rather than merely defensive.
fn pre_rps_marked(prev: Option<&AuPlan>) -> Vec<RefPic> {
let Some(prev) = prev else {
return Vec::new();
};
let mut marked = prev.dpb_refs.clone();
if let Some(id) = prev.dpb.stored {
if prev.picture.is_reference {
marked.push(RefPic {
id,
pic_order_cnt: prev.picture.pic_order_cnt,
is_long_term: false,
});
}
}
marked
}
/// The exemption, measured on the stream that can actually falsify it.
///
/// [`the_current_picture_is_named_by_curr_pic_and_never_aliases_a_reference`] runs
/// over `test-25fps.h265`, which REORDERS — a picture the RPS drops stays alive for
/// output past the access unit that dropped it, so the eviction and the unmarking
/// never land together and the vector cannot reach the precondition however hard it
/// is run. That is the same blindness that let the H.264 defect survive two
/// milestones behind a 250/250 green vector.
///
/// This stream reaches it. Three numbers, and the third is what gives the second
/// its meaning:
///
/// - **115 of 120** access units retire a picture (a five-picture DPB against four
/// marked references and `sps_max_num_reorder_pics = 0`);
/// - **0** of those retirements intersect the access unit's own `dpb_refs` — the
/// exemption, measured on our own encoder's output rather than argued;
/// - **115** of them intersect the PRE-RPS marked set, so a snapshot taken one call
/// earlier would alias on every single one.
///
/// [`the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps`]
/// then drives that counterfactual through the conversion itself.
#[test]
fn the_low_delay_stream_reaches_the_dpb_pressure_and_hevc_still_does_not_alias() {
let converted = convert_stream(LOWDELAY_640X480_H265);
assert_eq!(converted.len(), 120, "the low-delay stream is 120 pictures");
let mut with_removals = 0usize;
let mut both = 0usize;
let mut would_alias = 0usize;
for (i, (plan, dxva)) in converted.iter().enumerate() {
// The consequence, over every access unit of the stream: the decode target
// is `CurrPic` and appears in no reference entry.
assert_eq!(dxva.pic_params.CurrPic.index(), dxva.setup_slot);
assert!(!dxva.pic_params.CurrPic.associated());
for r in &dxva.refs {
assert_ne!(
r.slot, dxva.setup_slot,
"AU {i}: reference picture {} shares surface {} with the decode \
target HEVC has acquired the H.264/AV1 defect",
r.id, r.slot
);
}
assert!(
plan.picture.is_reference,
"AU {i}: this stream carries no sub-layer non-reference pictures, \
which is what makes `pre_rps_marked` exact"
);
if !plan.dpb.removed.is_empty() {
with_removals += 1;
}
both += plan
.dpb
.removed
.iter()
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
.count();
let before = pre_rps_marked(i.checked_sub(1).map(|prev| &converted[prev].0));
would_alias += plan
.dpb
.removed
.iter()
.filter(|id| before.iter().any(|r| r.id == **id))
.count();
}
assert_eq!(
with_removals, 115,
"the stream must still retire a picture on nearly every access unit; \
without that both numbers below are trivially zero"
);
assert_eq!(
both, 0,
"{both} picture(s) are in an access unit's own marked DPB AND removed by \
it the H.264/AV1 aliasing precondition, which HEVC is supposed to be \
structurally incapable of. `H265Planner`'s snapshot has moved ahead of \
`decode_rps`. Restore the ordering, or give this conversion the \
`release_after_decode` deferral the other two carry; do NOT relax this"
);
assert_eq!(
would_alias, 115,
"the fixture must stay CAPABLE of exposing the defect it rules out. A \
regenerated stream that reordered, or whose DPB was deeper than its \
reference count, would report 0 here and the zero above would then \
prove exactly as much as `test-25fps.h264`'s zero proved, which was nothing"
);
}
/// The counterfactual driven through the CONVERSION, not just the planner's
/// arithmetic.
///
/// `H265Planner` snapshotting one call earlier is a plausible refactor — it is
/// where `H264Planner` and `Av1Planner` both snapshot, and both needed
/// `release_after_decode` because of it. This test simulates exactly that by
/// handing `plan_to_dxva_h265` the PRE-RPS marked set as `dpb_refs` and nothing
/// else changed, then asserts the alias appears: the dropped picture enters
/// `RefPicList` as a *Foll* entry with its slot resolved BEFORE the removals are
/// released, `SlotMap::assign` hands that freed slot straight to `CurrPic`, and one
/// surface is named as both the decode target and a picture the frame predicts from.
///
/// So the guarantee is not "we looked and it was fine". It is: this stream reaches
/// the shape, this conversion breaks on it under the other snapshot ordering, and
/// the ordering we ship is why it does not.
#[test]
fn the_low_delay_stream_would_alias_if_the_snapshot_moved_ahead_of_the_rps() {
let mut planner = H265Planner::new();
let mut slots: Option<SlotMap> = None;
let mut plans: Vec<AuPlan> = Vec::new();
let mut aliased = 0usize;
let mut converted = 0usize;
for (i, au) in split_into_aus(LOWDELAY_640X480_H265)
.into_iter()
.enumerate()
{
let plan = planner.plan_au(au).expect("the low-delay stream plans");
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
// The ONE mutation: the marked DPB as it stood before this AU's RPS ran.
let mut as_if = plan.clone();
as_if.dpb_refs = pre_rps_marked(plans.last());
// The reconstruction validates itself, so a planner change that broke the
// reasoning behind `pre_rps_marked` fails HERE with its reason rather than
// quietly turning the count below into a different measurement. Marking
// only ever grows between two access units' RPS derivations (the previous
// picture is stored marked; C.5.2.2's removal takes only already-unmarked
// pictures), so the pre-RPS set is a strict SUPERSET of the post-RPS one.
for rp in plan.dpb_refs.iter().chain(
as_if
.rps
.st_curr_before
.iter()
.chain(&as_if.rps.st_curr_after)
.chain(&as_if.rps.lt_curr),
) {
assert!(
as_if.dpb_refs.iter().any(|r| r.id == rp.id),
"AU {i}: picture {} is in the post-RPS marked DPB (or a current \
set) but not in the reconstructed pre-RPS one marking is no \
longer monotone across an access unit boundary, and this test is \
measuring a different mutation than the one it documents",
rp.id
);
}
let dxva = plan_to_dxva_h265(&as_if, map, i as u32 + 1).expect("conversion");
converted += 1;
if dxva.refs.iter().any(|r| r.slot == dxva.setup_slot) {
aliased += 1;
}
plans.push(plan);
}
assert_eq!(converted, 120);
assert_eq!(
aliased, 115,
"the pre-RPS snapshot must alias on every access unit that retires a \
picture. {aliased} of 120 did if this is 0, the stream no longer \
reaches the shape and the exemption asserted by the test above is \
unfalsifiable again; regenerate the fixture rather than relaxing this"
);
}
#[test]
@@ -145,6 +145,47 @@
//! while every other byte still looks right. If the macro is spelled differently in the tree,
//! any expression yielding the negotiated config's `ConfigBitstreamRaw` will do.
//!
//! **5b. AV1.** The identical `PFPP` block goes at the very END of
//! `ff_dxva2_av1_fill_picture_parameters` (`dxva2_av1.c:60`), with `h264` replaced by `av1` —
//! after the film-grain block, so every field is final. Three things differ from the two codecs
//! above and each of them changes what a capture MEANS:
//!
//! * **The AU index is a FRAME, not a temporal unit.** `ff_dxva2_common_end_frame` runs once per
//! submitted picture and an AV1 temporal unit may decode several, so `pf_au_index` walks
//! decoded frames. This crate's [`our_av1_submissions`] emits one entry per decoded frame for
//! the same reason, and the vendored vector is **274** frames in 250 units — a capture with
//! 250 `PFPP av1` lines is a capture of something else. (A `show_existing_frame` unit submits
//! nothing on either side; this vector has none.)
//! * **No `PFQM` line and no matrix buffer.** `dxva2_av1_end_frame` passes `NULL, 0` for the qm
//! pair, so the `qm_size > 0` branch of the block in step 4 logs `absent` on every frame. That
//! is the expected reading, not a missed patch site.
//! * **No `PFCFG` check.** [`preflight`]'s `ConfigBitstreamRaw` assertion is about the two short
//! slice-control formats; AV1's slice-control record is `DXVA_Tile_AV1` and has no short/long
//! pair, so a captured `PFCFG av1` line is ignored rather than compared.
//!
//! The stream is the vendored IVF at
//! `crates/pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1`:
//!
//! ```text
//! ffmpeg -hwaccel d3d11va -hwaccel_output_format d3d11 -i test-25fps.ivf.av1 -f null - 2> av1.log
//! grep -oE 'PF(PP|QM|BD|CFG) .*' av1.log > libav-av1.capture
//! ```
//!
//! ⚠ Add `-export_side_data +film_grain` to NOTHING: `apply_grain` and `pp->coding.film_grain`
//! both turn OFF when film grain is exported as side data, and this crate always applies it in
//! the decoder. The vendored vector codes no grain either way.
//!
//! **No such capture has been taken.** As of 2026-08-07 the AV1 comparison
//! (`our_av1_picture_parameters_match_libavcodecs`) has never run against libavcodec's bytes,
//! and the reason is stated rather than left as an absent result: `.221`, the only box in this
//! fleet with a D3D11VA GPU to spare, has no MSYS2, no gcc and no make, so producing a patched
//! FFmpeg there is a toolchain bring-up rather than a build. What DID localise the AV1 defect
//! of 2026-08-07 was `video_d3d11_native`'s frame-hash parity harness plus a CPU invariant
//! (`no_av1_submission_names_its_decode_surface_in_the_reference_store`, below) — so this file's
//! AV1 half is currently the no-capture half only, and every claim it makes about libavcodec's
//! AV1 side is READ out of `dxva2_av1.c` (n8.1) rather than measured. Tier one, in the
//! provenance section's terms, for all of it.
//!
//! **6. Run it.** `--enable-d3d11va` is on by default on Windows. Decode the SAME elementary
//! streams this test plans — the vendored vectors, in the repository at
//! `crates/pf-bitstream/vendor/cros-codecs/src/codec/{h264,h265}/test_data/test-25fps.{h264,h265}`:
@@ -167,6 +208,7 @@
//!
//! ```text
//! PF_LIBAV_CAPTURE_H264=libav-h264.capture PF_LIBAV_CAPTURE_HEVC=libav-hevc.capture \
//! PF_LIBAV_CAPTURE_AV1=libav-av1.capture \
//! cargo test -p pf-dxvadec --test libav_picparams_parity -- --ignored --nocapture
//! ```
//!
@@ -352,13 +394,19 @@ use pf_dxvadec::dxva::QmatrixHevc;
use pf_dxvadec::dxva::SliceH264Short;
use pf_dxvadec::dxva::SliceHevcShort;
use pf_dxvadec::dxva::UNUSED_ENTRY;
use pf_dxvadec::dxva_av1::PicEntryAv1;
use pf_dxvadec::dxva_av1::UNUSED_INDEX;
use pf_dxvadec::AuPlan;
use pf_dxvadec::Av1Planner;
use pf_dxvadec::BufferDescriptor;
use pf_dxvadec::Codec;
use pf_dxvadec::H264Planner;
use pf_dxvadec::H265Planner;
use pf_dxvadec::PicParamsAv1;
use pf_dxvadec::SliceRecord;
use pf_dxvadec::SlotMap;
use pf_dxvadec::TileAv1;
use pf_dxvadec::NUM_REF_SLOTS;
const TEST_25FPS_H264: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
@@ -366,6 +414,11 @@ const TEST_25FPS_H264: &[u8] = include_bytes!(
const TEST_25FPS_H265: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265"
);
/// The AV1 vector is an IVF container, and its unit of comparison is the TEMPORAL UNIT rather
/// than the access unit: one IVF packet may decode several frames, of which at most one shows.
const TEST_25FPS_AV1: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/av1/test_data/test-25fps.ivf.av1"
);
/// Both vendored vectors carry exactly this many access units — pf-bitstream's own golden, and
/// the number of `PFPP` lines a valid capture holds.
@@ -453,8 +506,12 @@ struct OurSubmission {
qmatrix: Option<Vec<u8>>,
/// The descriptor set, in submission order.
descriptors: Vec<BufferDescriptor>,
/// The packer's slice records, for the internal-consistency checks.
/// The packer's slice records, for the internal-consistency checks. Empty on AV1, whose
/// slice-control buffer holds [`Self::tiles`] instead.
records: Vec<SliceRecord>,
/// AV1's slice-control records — one `DXVA_Tile_AV1` per TILE, not per tile group. Empty
/// on H.264 and H.265.
tiles: Vec<TileAv1>,
/// Bytes the packer wrote BEFORE the tail padding.
unpadded: u32,
/// `mb_width * mb_height` (H.264) or 0 (HEVC) — the value the descriptors must carry.
@@ -483,6 +540,19 @@ fn our_h264_submissions() -> Vec<OurSubmission> {
// libavcodec's `1 + report_id++` produces for a decoder that saw only this stream.
let dxva = pf_dxvadec::plan_to_dxva(&plan, map, out.len() as u32 + 1)
.unwrap_or_else(|e| panic!("AU {i} must convert: {e}"));
// The conversion's half of the deferral contract
// ([`pf_dxvadec::DecodePlanDxva::release_after_decode`]): a loop that converts
// AU after AU without applying it holds a surface per AU and runs the ledger
// dry. The vendored vector never puts a picture in both `RefFrameList` and
// `removed`, so this list is always empty HERE — applied anyway, because a
// harness that mirrors the caller only on the streams where it does not matter
// is a harness that would not notice the caller being wrong.
for &id in &dxva.release_after_decode {
assert!(
map.release(id),
"AU {i}: a deferred release named a picture holding no surface"
);
}
let packed = pf_dxvadec::pack(au, &dxva.slice_ranges, &mut mapping)
.unwrap_or_else(|e| panic!("AU {i} must pack: {e}"));
let unpadded = pf_dxvadec::packed_size(au, &dxva.slice_ranges).expect("packed size") as u32;
@@ -491,6 +561,7 @@ fn our_h264_submissions() -> Vec<OurSubmission> {
qmatrix: Some(pf_dxvadec::as_bytes(&dxva.qmatrix).to_vec()),
descriptors: pf_dxvadec::descriptors_h264(&dxva, &packed),
records: packed.records,
tiles: Vec::new(),
unpadded,
mb_count: dxva.mb_count,
});
@@ -527,6 +598,7 @@ fn our_hevc_submissions() -> Vec<OurSubmission> {
.map(|qm| pf_dxvadec::as_bytes(qm).to_vec()),
descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed),
records: packed.records,
tiles: Vec::new(),
unpadded,
mb_count: 0,
});
@@ -535,6 +607,85 @@ fn our_hevc_submissions() -> Vec<OurSubmission> {
out
}
/// Every AV1 FRAME the vendored vector decodes — 274, of which 250 are displayed.
///
/// The unit of comparison is the frame and not the temporal unit, because that is what
/// libavcodec's hwaccel counts: `ff_dxva2_common_end_frame` runs once per submitted PICTURE, so
/// a capture's AU index walks decoded frames. A `show_existing_frame` unit submits nothing and
/// appears on neither side; this vector has none.
const VENDORED_AV1_FRAMES: usize = 274;
/// Plan, convert and pack the whole vendored AV1 vector, one entry per decoded FRAME.
///
/// ⚠ This is the only one of the three that has to speak the conversion's DEFERRED RELEASE
/// contract ([`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`]). A loop that converts
/// without it holds a surface on 268 of these 274 frames and runs the nine-slot ledger dry
/// inside ten — and, worse for a harness, it would compare a submission built by a caller that
/// is not the rung.
fn our_av1_submissions() -> Vec<OurSubmission> {
let mut planner = Av1Planner::new();
let mut slots = SlotMap::new(NUM_REF_SLOTS);
let mut mapping = vec![0u8; MAPPING_BYTES];
let mut out = Vec::new();
for (i, unit) in split_ivf(TEST_25FPS_AV1).into_iter().enumerate() {
let plans = planner
.plan_au(unit)
.unwrap_or_else(|e| panic!("unit {i} of the vendored AV1 vector must plan: {e}"));
for plan in &plans {
if plan.dpb.stored.is_none() {
continue; // `show_existing_frame`: no submission at all
}
let dxva = pf_dxvadec::plan_to_dxva_av1(unit, plan, &mut slots)
.unwrap_or_else(|e| panic!("unit {i} must convert: {e}"));
let packed = pf_dxvadec::pack_av1(unit, &dxva.bitstream, &dxva.tiles, &mut mapping)
.unwrap_or_else(|e| panic!("unit {i} must pack: {e}"));
let unpadded = pf_dxvadec::packed_size_av1(&dxva.bitstream) as u32;
out.push(OurSubmission {
pic_params: pf_dxvadec::as_bytes(&dxva.pic_params).to_vec(),
// AV1 transmits no quantization matrix at all: its matrices are SELECTED by
// index out of tables the decoder already has, and `dxva2_av1_end_frame`
// passes `NULL, 0` for the pair. `None` here is a fact about the codec, not a
// condition on the stream the way HEVC's is.
qmatrix: None,
descriptors: pf_dxvadec::descriptors_av1(&packed),
// AV1's slice-control records are `DXVA_Tile_AV1`, a different struct with a
// different size; the shared `SliceRecord` checks do not apply to them, and
// the tile records get their own test rather than a coerced one.
records: Vec::new(),
tiles: packed.tiles.clone(),
unpadded,
mb_count: 0,
});
for &id in &dxva.release_after_decode {
assert!(
slots.release(id),
"unit {i}: a deferred release named a picture holding no surface"
);
}
}
}
assert_eq!(out.len(), VENDORED_AV1_FRAMES);
out
}
/// The IVF frame walk — the same one `video_d3d11_native`'s parity module and every AV1 test in
/// this program use: a 32-byte file header, then a 12-byte header per packet carrying its size.
fn split_ivf(stream: &[u8]) -> Vec<&[u8]> {
let mut out = Vec::new();
let mut at = 32usize;
while at + 12 <= stream.len() {
let size = u32::from_le_bytes([stream[at], stream[at + 1], stream[at + 2], stream[at + 3]])
as usize;
at += 12;
if at + size > stream.len() {
break;
}
out.push(&stream[at..at + size]);
at += size;
}
out
}
// ---------------------------------------------------------------------------
// Offset → field name
// ---------------------------------------------------------------------------
@@ -543,9 +694,12 @@ fn our_hevc_submissions() -> Vec<OurSubmission> {
/// order, built from the field IDENTIFIERS so a name and the offset it reports cannot drift
/// apart — the whole point of the table is to turn a differing byte into a field name, and a
/// table with a copy-pasted mismatch would name the wrong one.
/// Nested paths (`tiles.cols`) are accepted as well as plain identifiers, and are named by
/// the whole path — AV1's picture parameters are eight nested blocks, and a table that could
/// only reach the outer members would report "segmentation differs" for a 140-byte struct.
macro_rules! field_table {
($ty:ty, $($field:ident),+ $(,)?) => {
&[$((stringify!($field), offset_of!($ty, $field))),+]
($ty:ty, $($($field:ident).+),+ $(,)?) => {
&[$((stringify!($($field).+), offset_of!($ty, $($field).+))),+]
};
}
@@ -670,6 +824,87 @@ const HEVC_QMATRIX_FIELDS: &[(&str, usize)] = field_table!(
ucScalingListDCCoefSizeID3,
);
/// Every field of `DXVA_PicParams_AV1`, same construction — but reaching INTO the eight nested
/// blocks, because they are where AV1 keeps almost the whole frame header. `tiles` alone is 260
/// bytes and `segmentation` 140; a table stopping at the outer members would turn every finding
/// in them into one useless name.
///
/// Three members stay whole on purpose. `frame_refs` is seven 36-byte entries which — unlike
/// the other two codecs' reference arrays — carry NO surface index and so compare byte for
/// byte: `Index` is `ref_frame_idx[name]`, an AV1 SLOT both sides read out of the same frame
/// header. `ref_frame_map_texture_index` is the surface array, which cannot be compared by
/// value at all ([`av1_reference_store`]). And `film_grain` is 158 bytes neither vendored
/// vector codes.
const AV1_FIELDS: &[(&str, usize)] = field_table!(
PicParamsAv1,
width,
height,
max_width,
max_height,
curr_pic_texture_index,
superres_denom,
bitdepth,
seq_profile,
tiles.cols,
tiles.rows,
tiles.context_update_id,
tiles.widths,
tiles.heights,
coding,
format,
primary_ref_frame,
order_hint,
order_hint_bits,
frame_refs,
ref_frame_map_texture_index,
loop_filter.filter_level,
loop_filter.filter_level_u,
loop_filter.filter_level_v,
loop_filter.sharpness_level,
loop_filter.control_flags,
loop_filter.ref_deltas,
loop_filter.mode_deltas,
loop_filter.delta_lf_res,
loop_filter.frame_restoration_type,
loop_filter.log2_restoration_unit_size,
loop_filter.reserved16,
quantization.control_flags,
quantization.base_qindex,
quantization.y_dc_delta_q,
quantization.u_dc_delta_q,
quantization.v_dc_delta_q,
quantization.u_ac_delta_q,
quantization.v_ac_delta_q,
quantization.qm_y,
quantization.qm_u,
quantization.qm_v,
quantization.reserved16,
cdef.control_flags,
cdef.y_strengths,
cdef.uv_strengths,
interp_filter,
segmentation.control_flags,
segmentation.reserved24,
segmentation.feature_mask,
segmentation.feature_data,
film_grain,
reserved32,
status_report_feedback_number,
);
/// `DXVA_Tile_AV1` — AV1's slice-control record, and the one this crate had to derive rather
/// than measure (`dxva.h` declares it; the SIZE is what the descriptor states).
const AV1_TILE_FIELDS: &[(&str, usize)] = field_table!(
TileAv1,
data_offset,
data_size,
row,
column,
reserved16,
anchor_frame,
reserved8,
);
/// Turn a field table into `(name, byte range)`, the last field running to `total`.
fn field_ranges(
fields: &[(&'static str, usize)],
@@ -1038,6 +1273,37 @@ fn hevc_ref_entries(pp: &[u8]) -> Vec<(u8, RefEntry)> {
.collect()
}
/// One side's AV1 reference store, read out of the SUBMITTED BYTES: `(CurrPicTextureIndex,
/// RefFrameMapTextureIndex[8], frame_refs[name].Index for the seven names)`.
///
/// AV1's reference numbering is two arrays that mean different things at once and the split is
/// exactly what a comparison has to respect. `frame_refs[i].Index` is an AV1 reference SLOT —
/// `ref_frame_idx[i]`, which both sides read out of the same frame header — so it is a VALUE
/// that must match libavcodec's exactly, and it is compared as part of the `frame_refs` field.
/// `RefFrameMapTextureIndex[slot]` and `CurrPicTextureIndex` are SURFACES, which come from each
/// side's own pool and are only ever a bijection.
///
/// So the store is compared as a SHAPE: which slots are occupied, and whether the decode target
/// collides with any of them.
fn av1_reference_store(pp: &[u8]) -> (u8, [u8; 8], [u8; 7]) {
let curr = pp[offset_of!(PicParamsAv1, curr_pic_texture_index)];
let mut store = [UNUSED_INDEX; 8];
let base = offset_of!(PicParamsAv1, ref_frame_map_texture_index);
store.copy_from_slice(&pp[base..base + 8]);
let mut names = [UNUSED_INDEX; 7];
// The stride and the member offset come from the TYPE, never from the two numbers
// `dxva_av1.rs` measured (36 and 33). Those are pinned there as compile-time assertions
// against the Windows SDK's own header, and re-typing them here would be a second copy that
// can drift from the first — which for a reader of this array is the difference between a
// reference slot and a warp coefficient.
for (name, slot) in names.iter_mut().enumerate() {
*slot = pp[offset_of!(PicParamsAv1, frame_refs)
+ name * size_of::<PicEntryAv1>()
+ offset_of!(PicEntryAv1, index)];
}
(curr, store, names)
}
/// The surface mapping between the two sides, tracked per PICTURE.
///
/// A global index-to-index bijection over a whole stream is the wrong model: both sides reuse a
@@ -1287,10 +1553,17 @@ fn preflight(capture: &Capture, ours: usize, codec: &str, reserved16: Option<usi
}
// A `PFCFG` line is optional, but a wrong one voids the slice-control comparison: it means
// the driver read the other slice-control struct entirely.
let want = pf_dxvadec::short_slice_config(match codec {
"h264" => Codec::H264,
_ => Codec::H265,
});
//
// ⚠ AV1 is exempt, and not because the check is inconvenient: `ConfigBitstreamRaw`'s short
// format is a property of the two SHORT SLICE-CONTROL structs, and AV1's slice-control
// record is `DXVA_Tile_AV1`, which has no short/long pair for a config to select between.
// Comparing an AV1 capture's number against HEVC's 1 — which a `_ =>` arm would do — is a
// check of nothing that fails on anything.
let want = match codec {
"h264" => pf_dxvadec::short_slice_config(Codec::H264),
"hevc" => pf_dxvadec::short_slice_config(Codec::H265),
_ => return,
};
for (au, &raw) in &capture.config_bitstream_raw {
assert_eq!(
raw, want,
@@ -1568,6 +1841,108 @@ fn hevc_rps_pictures(pp: &[u8], array: usize, entries: &[(u8, RefEntry)]) -> Vec
.collect()
}
/// The whole AV1 picture-parameter comparison.
///
/// Structurally simpler than the other two and the reason is worth stating: AV1 puts NO surface
/// index in its reference entries. `frame_refs[i].Index` is `ref_frame_idx[i]`, an AV1 SLOT both
/// sides read out of the same frame header, so the seven 36-byte entries — sizes, warp
/// parameters, warp type and slot alike — compare byte for byte with no re-indexing, no set
/// comparison and no allowance. Only two members carry surfaces, and they are handled as the
/// SHAPE of the store rather than by value ([`av1_reference_store`]).
///
/// There is no POC base to derive either: AV1's `order_hint` is a coded field, not a decoder's
/// running count, so libavcodec has nothing to seed it with.
///
/// ⚠ **`width`/`height` is a divergence waiting to be measured, and this comparison will
/// report it rather than absorb it.** libavcodec sends `avctx->width`, which
/// `update_context_with_frame_header` sets from `frame_width_minus_1 + 1` — FrameWidth, the
/// PRE-superres coded width — and the same for `frame_refs[i].width` off the reference's
/// `AVFrame`; this crate sends `UpscaledWidth`. With superres off the two are equal by
/// definition (7.20), which is every frame of the vendored vector and every frame a punktfunk
/// host emits, so a capture made from this vector cannot tell them apart. Deliberately given no
/// allowance: if a superres capture ever reaches this harness, the difference must be a finding
/// somebody reads, not a line somebody already excused. See `pic_av1.rs`'s note at `pp.width`.
fn compare_av1_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings {
let ranges = field_ranges(AV1_FIELDS, size_of::<PicParamsAv1>());
// The two surface arrays, and nothing else: every other byte of this struct is a fact about
// the bitstream that both sides derive from the same frame header.
let structural = ["curr_pic_texture_index", "ref_frame_map_texture_index"];
let mut findings = Findings::default();
for (au, sub) in ours.iter().enumerate() {
let Some(theirs) = capture.pic_params.get(&au) else {
findings.note(
"<no capture>",
au,
"the capture holds no PFPP line for this frame",
);
continue;
};
if theirs.len() != sub.pic_params.len() {
findings.note(
"<struct size>",
au,
format!(
"the capture's picture parameters are {} bytes and ours are {}",
theirs.len(),
sub.pic_params.len()
),
);
continue;
}
compare_scalars(
au,
&sub.pic_params,
theirs,
&ranges,
&structural,
no_allowance,
&mut findings,
);
// The store, as a shape. Which SLOTS hold a picture is a fact about the bitstream and
// must agree; which SURFACE each holds is each side's own pool and never can.
let (our_curr, our_store, _) = av1_reference_store(&sub.pic_params);
let (their_curr, their_store, _) = av1_reference_store(theirs);
for slot in 0..8 {
let ours_occupied = our_store[slot] != UNUSED_INDEX;
let theirs_occupied = their_store[slot] != UNUSED_INDEX;
if ours_occupied != theirs_occupied {
findings.note(
format!("ref_frame_map_texture_index[{slot}][occupied]"),
au,
format!("ours {ours_occupied}, libav {theirs_occupied}"),
);
}
}
// The decode target must hold no store entry's surface. libavcodec cannot produce a
// collision — it fills the store from `h->ref[i]`, which the reference update has not
// run on yet, and takes `CurrPicTextureIndex` from `h->cur_frame.f` — so a collision on
// our side is a defect however the surfaces are numbered. This is the check that names
// the 2026-08-07 defect.
//
// ⚠ Note what is deliberately NOT checked: that two slots hold different surfaces. One
// picture in several reference slots is ordinary AV1 and this very vector does it —
// the key frame sits in BWDREF and ALTREF2 for the stream's whole length — so a
// "duplicate surface" check would fire on 273 of 274 frames of a correct conversion.
for (label, curr, store) in [
("ours", our_curr, our_store),
("libav", their_curr, their_store),
] {
if store.contains(&curr) {
findings.note(
"curr_pic_texture_index[aliases the store]",
au,
format!(
"{label}: surface {curr} is both the decode target and a reference \
store entry the frame decodes into a picture it predicts from"
),
);
}
}
}
findings
}
/// The whole HEVC picture-parameter comparison.
fn compare_hevc_picparams(ours: &[OurSubmission], capture: &Capture) -> Findings {
let ranges = field_ranges(HEVC_FIELDS, size_of::<PicParamsHevc>());
@@ -1970,6 +2345,13 @@ fn every_hand_declared_dxva_struct_is_tiled_exactly_by_its_fields() {
HEVC_SLICE_FIELDS,
size_of::<SliceHevcShort>(),
),
// AV1's two. `PicParamsAv1` is the one struct in this crate whose offsets were
// MEASURED rather than mirrored — `layout-probe-av1.c` compiled with MSVC against the
// Windows SDK's own `dxva.h` — and `dxva_av1.rs` pins every one at compile time. What
// this adds is the other half: that the TABLE above reaches all 912 bytes, so a
// capture comparison can name every one of them.
("PicParamsAv1", AV1_FIELDS, size_of::<PicParamsAv1>()),
("TileAv1", AV1_TILE_FIELDS, size_of::<TileAv1>()),
] {
assert_eq!(fields[0].1, 0, "{what}: the first field must start at 0");
let ranges = field_ranges(fields, total);
@@ -2006,6 +2388,196 @@ fn every_h264_au_submits_four_buffers_in_libavcodecs_order() {
}
}
/// **No AV1 submission names its decode surface anywhere in the reference store**, and every
/// reference NAME resolves through a slot that holds one.
///
/// This is the defect the Windows parity harness caught on 2026-08-07 and the one nothing on
/// the CPU could see, stated over the SUBMITTED BYTES — which is where a libavcodec capture
/// would see it too, and the reason it belongs in this file as well as in `pic_av1`'s own
/// tests. `plan_to_dxva_av1` released the picture this frame's own `refresh_frame_flags`
/// displaces before assigning the decode target a slot, and `SlotMap::assign` hands back the
/// slot just vacated — so `CurrPicTextureIndex` and one `RefFrameMapTextureIndex` entry were
/// the same surface on 268 of these 274 frames: decode into the picture you predict from.
/// Intel Arc followed the aliased surface and got 245 of 250 delivered frames wrong; NVIDIA
/// tolerated it for 63 frames and then lost one 16x24 luma block at the `order_hint` wrap.
///
/// libavcodec cannot produce this shape and that is the whole argument for calling it a defect
/// rather than a convention: `ff_dxva2_av1_fill_picture_parameters` fills
/// `RefFrameMapTextureIndex` from `h->ref[i]`, the pre-refresh store, and takes
/// `CurrPicTextureIndex` from `h->cur_frame.f`, a frame the reference-frame update has not run
/// on yet. The two cannot be one surface.
///
/// The counts are asserted, not printed. At zero references this test would pass against a
/// conversion that named nothing at all.
#[test]
fn no_av1_submission_names_its_decode_surface_in_the_reference_store() {
let subs = our_av1_submissions();
let (mut with_store, mut named_refs) = (0usize, 0usize);
for (frame, sub) in subs.iter().enumerate() {
let (curr, store, names) = av1_reference_store(&sub.pic_params);
assert!(
store.iter().all(|surface| *surface != curr),
"frame {frame}: surface {curr} is both CurrPicTextureIndex and a \
RefFrameMapTextureIndex entry"
);
if store.iter().any(|s| *s != UNUSED_INDEX) {
with_store += 1;
}
for (name, slot) in names.iter().enumerate() {
if *slot == UNUSED_INDEX {
continue;
}
named_refs += 1;
assert!(
usize::from(*slot) < store.len(),
"frame {frame}, reference name {name}: slot {slot} is outside the eight-entry \
store `Index` is an AV1 reference SLOT, not a surface"
);
assert_ne!(
store[usize::from(*slot)],
UNUSED_INDEX,
"frame {frame}, reference name {name}: slot {slot} holds no surface, so the \
driver would follow `Index` into an empty entry"
);
}
}
assert_eq!(
with_store, 273,
"every frame but the opening key frame carries a populated reference store"
);
assert!(
named_refs > 0,
"no frame named a reference, so every check above was skipped"
);
}
/// AV1 submits THREE buffers and never a quantization matrix, on every frame of the vector.
///
/// The codec asymmetry the H.264 and HEVC tests above are about, taken to its third case.
/// H.264 submits the matrix unconditionally, HEVC only under `scaling_list_enabled_flag`, and
/// AV1 has no matrix BUFFER at all: its quantiser matrices are SELECTED by index
/// (`qm_y`/`qm_u`/`qm_v`) out of tables the decoder already holds, and `dxva2_av1_end_frame`
/// passes `NULL, 0` for the qm pair so the generic layer submits nothing. A fourth descriptor
/// here would be a buffer the driver has no `DXVA_Qmatrix_AV1` to read it as.
#[test]
fn every_av1_frame_submits_three_buffers_and_never_a_quantization_matrix() {
for (frame, sub) in our_av1_submissions().iter().enumerate() {
assert_eq!(
sub.descriptors
.iter()
.map(|d| d.buffer_type)
.collect::<Vec<_>>(),
vec![
BUFFER_PICTURE_PARAMETERS,
BUFFER_BITSTREAM,
BUFFER_SLICE_CONTROL,
],
"frame {frame}"
);
assert!(sub.qmatrix.is_none(), "frame {frame}");
}
}
/// No AV1 descriptor carries a macroblock count. AV1 has no macroblocks and `dxva2_av1.c`
/// never touches the field — the same statement `no_hevc_descriptor_ever_carries_a_macroblock_count`
/// makes, and the same defect class review 13 found on the H.264 side in the other direction.
#[test]
fn no_av1_descriptor_ever_carries_a_macroblock_count() {
for (frame, sub) in our_av1_submissions().iter().enumerate() {
assert_eq!(sub.mb_count, 0, "frame {frame}");
for desc in &sub.descriptors {
assert_eq!(
desc.num_mbs_in_buffer,
0,
"frame {frame}, {}",
buffer_name(desc.buffer_type)
);
}
}
}
/// AV1's slice-control buffer is `16 * tile count`, its bitstream descriptor is the packer's
/// PADDED size, and the tile records tile the unpadded window exactly — in order, without gaps
/// and without overlaps.
///
/// The last part is what distinguishes AV1 from the other two codecs here and is the reason
/// this cannot reuse `the_bitstream_descriptor_is_the_packers_padded_size_and_the_slice_records_tile_it_exactly`:
/// a `DXVA_Tile_AV1` addresses a TILE PAYLOAD, which is the bytes after that tile's
/// `tile_size_minus_1` field — so consecutive records are separated by those size fields and do
/// NOT abut, unlike H.264/HEVC slice records which tile their buffer with no gaps. What must
/// hold is weaker and still exact: strictly increasing, non-overlapping, inside the unpadded
/// window, and never starting at a tile-group OBU's first byte (which would hand the driver an
/// OBU header as entropy-coded tile data).
///
/// ⚠ The padding is charged to NO record. H.264 and HEVC add the tail padding to their last
/// slice record's `SliceBytesInBuffer`; `pack_av1` does not, because a tile's size is the
/// tile's, and the descriptor is the only place AV1's padding is accounted at all.
#[test]
fn the_av1_bitstream_descriptor_is_padded_and_the_tile_records_tile_it_without_overlapping() {
let mut frames_with_padding = 0usize;
for (frame, sub) in our_av1_submissions().iter().enumerate() {
let bitstream = sub
.descriptors
.iter()
.find(|d| d.buffer_type == BUFFER_BITSTREAM)
.unwrap_or_else(|| panic!("frame {frame} submits no bitstream buffer"));
assert_eq!(
bitstream.data_size % 128,
0,
"frame {frame}: the bitstream descriptor states the PADDED size"
);
// ⚠ `1..=128`, not `0..128`. `pack_av1` writes libavcodec's expression verbatim —
// `BITSTREAM_ALIGN - (cursor % BITSTREAM_ALIGN)` — so data that is ALREADY on the
// granule gets a whole 128-byte block rather than none (`pack_av1`'s
// `data_already_on_the_granule_still_gets_a_full_padding_block`). This vector never
// lands on the granule, which is exactly why the bound has to come from the rule and
// not from the measurement.
let padding = bitstream.data_size - sub.unpadded;
assert!(
(1..=128).contains(&padding),
"frame {frame}: {padding} bytes of padding"
);
frames_with_padding += 1;
let slice_control = sub
.descriptors
.iter()
.find(|d| d.buffer_type == BUFFER_SLICE_CONTROL)
.unwrap_or_else(|| panic!("frame {frame} submits no slice-control buffer"));
assert_eq!(
slice_control.data_size as usize,
size_of::<TileAv1>() * sub.tiles.len(),
"frame {frame}: sixteen bytes per TILE"
);
assert!(!sub.tiles.is_empty(), "frame {frame}: a frame has tiles");
let mut previous_end = 0u32;
for (i, tile) in sub.tiles.iter().enumerate() {
// `#[repr(packed)]` — copy the fields out before using them.
let (offset, size) = (tile.data_offset, tile.data_size);
assert!(size > 0, "frame {frame}, tile {i}: an empty tile payload");
assert!(
offset >= previous_end,
"frame {frame}, tile {i}: starts at {offset}, inside the previous tile which \
ends at {previous_end}"
);
assert!(
offset + size <= sub.unpadded,
"frame {frame}, tile {i}: runs past the bytes the packer wrote"
);
previous_end = offset + size;
assert_eq!(
tile.anchor_frame, UNUSED_INDEX,
"frame {frame}, tile {i}: large-scale-tile anchors are libavcodec's 0xFF"
);
}
}
assert_eq!(
frames_with_padding, VENDORED_AV1_FRAMES,
"every frame is padded — the rule is unconditional"
);
}
#[test]
fn every_h264_bitstream_and_slice_control_descriptor_carries_mb_width_times_mb_height() {
// Review 13's defect, over the whole vector: `NumMBsInBuffer` was 0 where libavcodec's
@@ -2281,6 +2853,7 @@ fn the_picture_parameter_buffer_is_the_whole_hand_declared_struct_for_both_codec
for (codec, subs, size) in [
("h264", our_h264_submissions(), size_of::<PicParamsH264>()),
("hevc", our_hevc_submissions(), size_of::<PicParamsHevc>()),
("av1", our_av1_submissions(), size_of::<PicParamsAv1>()),
] {
for (au, sub) in subs.iter().enumerate() {
assert_eq!(sub.pic_params.len(), size, "{codec} AU {au}");
@@ -2347,6 +2920,7 @@ fn hevc_case(enabled: bool, sps_coded: Option<u8>, pps_coded: Option<u8>) -> Our
.map(|qm| pf_dxvadec::as_bytes(qm).to_vec()),
descriptors: pf_dxvadec::descriptors_h265(&dxva, &packed),
records: packed.records,
tiles: Vec::new(),
unpadded,
mb_count: 0,
}
@@ -2508,6 +3082,35 @@ fn the_dump_and_the_parser_agree_and_the_comparison_finds_nothing_against_oursel
// The HEVC matrices are `absent` on this vector, and the parser must carry that fact rather
// than losing it — the whole of review 13's defect is the difference between the two.
assert!(capture.qmatrix.values().all(Option::is_none));
// AV1, on all 274 FRAMES. Nothing here has ever been run against libavcodec's own bytes
// (module docs say why), so this self-comparison is the only thing standing between
// `compare_av1_picparams` and a first capture: it proves the 912-byte field table reaches
// every byte, that `av1_reference_store` reads `CurrPicTextureIndex` and the eight-entry
// store from the offsets it thinks it does — a wrong one would report a false alias on a
// correct submission — and that the comparison invents nothing on identical input.
let ours = our_av1_submissions();
let capture = parse_capture(&dump("av1", &ours), "av1");
preflight(&capture, ours.len(), "av1", None);
assert_eq!(capture.pic_params.len(), VENDORED_AV1_FRAMES);
for findings in [
compare_av1_picparams(&ours, &capture),
compare_descriptors(&ours, &capture),
] {
assert!(
findings.is_empty(),
"comparing our own AV1 bytes against themselves must find nothing, got {:?}",
findings.fields()
);
assert!(
findings.documented_fields().is_empty(),
"identical bytes documented a divergence: {:?}",
findings.documented_fields()
);
}
// AV1 reports `absent` on every frame — it has no matrix BUFFER at all, unlike HEVC where
// the same spelling is a per-sequence decision.
assert!(capture.qmatrix.values().all(Option::is_none));
}
/// A submission holding only what [`compare_descriptors`] reads, for the bitstream-size
@@ -2527,6 +3130,7 @@ fn descriptor_only_submission(unpadded: u32, padded: u32, slices: usize) -> OurS
OurSubmission {
pic_params: vec![0u8; size_of::<PicParamsH264>()],
qmatrix: None,
tiles: Vec::new(),
descriptors: vec![
BufferDescriptor {
buffer_type: BUFFER_BITSTREAM,
@@ -2698,6 +3302,7 @@ fn the_hevc_tiles_flag_allowance_is_exactly_bit_ten_with_tiles_disabled_and_noth
qmatrix: sub.qmatrix.clone(),
descriptors: sub.descriptors.clone(),
records: sub.records.clone(),
tiles: sub.tiles.clone(),
unpadded: sub.unpadded,
mb_count: sub.mb_count,
}
@@ -3080,12 +3685,28 @@ fn renumber_h264_surfaces(pp: &[u8], f: impl Fn(u8) -> u8) -> Vec<u8> {
/// Emit this crate's whole submission — both codecs — in the capture's own format, so the two
/// files can be diffed by any tool without a capture at all.
#[test]
#[ignore = "needs a libavcodec capture: PF_LIBAV_CAPTURE_AV1=<file> (see the module docs)"]
fn our_av1_picture_parameters_match_libavcodecs() {
let capture = capture_from_env("PF_LIBAV_CAPTURE_AV1", "av1")
.expect("PF_LIBAV_CAPTURE_AV1=<file> names a capture (see the module docs)");
let ours = our_av1_submissions();
// No `Reserved16Bits` preflight: both libavcodec workarounds are H.264-only
// (`dxva2_h264.c`), and `DXVA_PicParams_AV1` has no such field to test.
preflight(&capture, ours.len(), "av1", None);
compare_av1_picparams(&ours, &capture).verdict("AV1 picture parameters", ours.len());
}
#[test]
#[ignore = "writes a dump: PF_DXVA_DUMP=<path>"]
fn dump_our_submission_in_the_captures_own_format() {
let path = std::env::var("PF_DXVA_DUMP").expect("PF_DXVA_DUMP=<path> names the output file");
let mut text = dump("h264", &our_h264_submissions());
text.push_str(&dump("hevc", &our_hevc_submissions()));
// AV1 too, and it is the codec that needs this most: no libavcodec AV1 capture has
// ever been taken (module docs say why), so for that codec this dump is the only
// way to read what the driver is being handed at all.
text.push_str(&dump("av1", &our_av1_submissions()));
std::fs::write(&path, text).expect("write the dump");
println!("wrote {path}");
}
+30
View File
@@ -137,6 +137,36 @@ pub(super) fn subframe_env_forced() -> bool {
/// drop-in alternative — `poll_chunk` cuts at `bitstreamSizeInBytes` on the reasoning that
/// "slices are contiguous Annex-B", which AV1's OBUs are not.
///
/// # A tile-aware chunk reader was considered and is CLOSED, not deferred
///
/// The obvious follow-up is to teach the reader AV1's units — cut on OBU boundaries instead
/// of byte counts and arm from the driver's reported unit count — so AV1 gets the sub-frame
/// latency win HEVC gets (ship tile 1 while tile 2 encodes). Measured on `.21` (RTX 5070 Ti,
/// `av1_nvenc`, 2026-08-07) before writing any of it, and the measurement closes it:
///
/// * **4K carries two tiles, and they share ONE Tile Group OBU.** The frame header reads
/// `width_in_sbs_minus_1[0] = 59` (one tile column, the full 3840) and
/// `height_in_sbs_minus_1[] = {16, 16}` (two tile rows) — but
/// `tile_start_and_end_present_flag = 0`, which puts both tiles in a single Tile Group
/// OBU. There is no OBU boundary between them to cut on. Shipping tile 1 early would mean
/// the HOST re-authoring AV1 syntax per chunk — synthesising a fresh Tile Group OBU header
/// with `tile_start_and_end_present_flag = 1` and its own `tg_start`/`tg_end` — which is
/// bitstream surgery on the encode path, not a reader change.
/// * **1080p carries one tile** (`tile_cols_log2 = tile_rows_log2 = 0`), so there is nothing
/// to pipeline at the commonest streaming resolution regardless.
/// * **The prize is small even at 4K, because split encode already spent it.** The two tile
/// rows go to two split-encode engines that run CONCURRENTLY, so they finish at nearly the
/// same moment: the win is bounded by the skew between engines, not by half the frame.
/// Whole-frame encode measures 3.33.6 ms at 4K60 against a 16.7 ms p50 end-to-end, so
/// even the sequential-tiles fantasy caps out near 1.7 ms and the real figure is a
/// fraction of that. HEVC's sub-frame win is larger for a structural reason that does not
/// transfer: forced split and sub-frame are mutually unsupported (below), so HEVC's slices
/// really are produced one after another.
///
/// Reopen only if NVENC starts emitting one OBU per tile, or sets
/// `tile_start_and_end_present_flag = 1` — at that point the cut points exist and the reader
/// change becomes the small piece it was assumed to be.
///
/// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back
/// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave
/// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without
+48
View File
@@ -505,5 +505,53 @@ int main(void) {
S(VAConfigAttrib);
O(VAConfigAttrib, type);
O(VAConfigAttrib, value);
/*
* The IMAGE pair `vaDeriveImage` / `vaCreateImage` + `vaGetImage` write these,
* and they are the only way anything reads a decoded VAAPI surface back on the
* CPU. `VAImage` is the awkward one of the whole file: `width` and `height` are
* `unsigned short`, so the four-byte fields around them are NOT where counting
* 32-bit words would put them, and `component_order` is four `char` rather than a
* padded word. Both are measured here rather than reasoned about.
*
* The readback these describe is TEST-ONLY (see `video_vaapi_native`'s `parity`
* module). The production path exports a DRM-PRIME dmabuf and never maps a
* surface; the structures are declared for the same reason every other structure
* in this file is, so a parity harness can be written without a libva build
* dependency.
*/
S(VAImageFormat);
O(VAImageFormat, fourcc);
O(VAImageFormat, byte_order);
O(VAImageFormat, bits_per_pixel);
O(VAImageFormat, depth);
O(VAImageFormat, red_mask);
O(VAImageFormat, green_mask);
O(VAImageFormat, blue_mask);
O(VAImageFormat, alpha_mask);
O(VAImageFormat, va_reserved);
S(VAImage);
O(VAImage, image_id);
O(VAImage, format);
O(VAImage, buf);
O(VAImage, width);
O(VAImage, height);
O(VAImage, data_size);
O(VAImage, num_planes);
O(VAImage, pitches);
O(VAImage, offsets);
O(VAImage, num_palette_entries);
O(VAImage, entry_bytes);
O(VAImage, component_order);
O(VAImage, va_reserved);
printf("count VAImage pitches %zu\n",
sizeof(((VAImage *)0)->pitches) / sizeof(((VAImage *)0)->pitches[0]));
printf("count VAImage offsets %zu\n",
sizeof(((VAImage *)0)->offsets) / sizeof(((VAImage *)0)->offsets[0]));
printf("count VAImage component_order %zu\n",
sizeof(((VAImage *)0)->component_order));
printf("enum VA_LSB_FIRST %d\n", VA_LSB_FIRST);
printf("enum VA_MSB_FIRST %d\n", VA_MSB_FIRST);
return 0;
}
+28 -4
View File
@@ -23,10 +23,18 @@
//! everything decidable without a device — including [`drm`], the export
//! descriptor the driver writes back and the plane walk that reads it.
//!
//! ⚠ **Nothing here has decoded a frame.** The rung is pin-only
//! (`PUNKTFUNK_DECODER=native-vaapi`) and no VAAPI hardware has been reachable
//! during M7, so everything below is a CPU-side conversion checked against
//! libavcodec and against measured layouts, not against a picture.
//! **Every conversion in this crate has now been checked in PIXELS.** On 2026-08-08,
//! on `.25` (Radeon 780M, RDNA3, radeonsi, Mesa 26.0.3, VA-API 1.23),
//! `pf-client-core`'s `video_vaapi_native::parity` decoded seven streams through the
//! rung and hashed every delivered frame against libavcodec's software decode — the
//! same golden files the Vulkan and D3D11VA rungs are held to — and all seven came
//! back bit-identical: 250 + 120 H.264, 250 + 120 H.265, 50 HEVC Main 10 (P010), and
//! 250 + 60 AV1. That was possible at all because [`va::pack_two_plane`] and the
//! `VAImage` pair below give a TEST-ONLY readback of a decoded surface; nothing on the
//! production path maps one, and that module's docs say how it is kept that way.
//!
//! ⚠ ONE vendor. AMD/radeonsi only — Intel's iHD driver has neither run these legs nor
//! been asked to.
//!
//! Five things this crate settled that a reader would otherwise have to re-derive:
//!
@@ -150,3 +158,19 @@ pub use va::VaIqMatrixBufferH264;
pub use va::VaPictureH264;
pub use va::VaPictureParameterBufferH264;
pub use va::VaSliceParameterBufferH264;
// The CPU-readable view of a decoded surface, and the pure walk that packs one into
// the layout this program's goldens hash.
//
// ⚠ TEST-ONLY. Nothing on the production video path maps a surface — the rung exports
// a DRM-PRIME dmabuf and the presenter samples it, which is the zero-copy contract —
// so the only caller is `pf-client-core`'s `video_vaapi_native::parity`, which exists
// solely under `#[cfg(test)]`. These are declared here so that harness needs no
// `libva-dev` and so its geometry can be checked with no device at all (`va`'s module
// docs say why at length).
pub use va::pack_two_plane;
pub use va::packed_len;
pub use va::ImageReadError;
pub use va::VaImage;
pub use va::VaImageFormat;
pub use va::VA_LSB_FIRST;
+297 -55
View File
@@ -549,6 +549,20 @@ mod tests {
"../../pf-bitstream/vendor/cros-codecs/src/codec/h264/test_data/test-25fps.h264"
);
/// A punktfunk HOST's own output: 120 pictures of 640x480, `max_num_ref_frames = 3`
/// alongside `max_dec_frame_buffering = 3` and `max_num_reorder_frames = 0`.
///
/// Vendored beside `pf-vkdecode`'s per-frame goldens, and the only stream in the
/// tree that produces the shape this module's exemption is about. The conformance
/// vector above cannot: its level gives it a 7-frame DPB against 2 reference
/// frames, so 8.2.5's sliding window unmarks a picture two access units before
/// C.4.5.3's bump can evict it, and it reorders, which keeps an unmarked picture
/// alive past the unit that unmarked it. Both are properties of that vector rather
/// than of H.264, and between them they hid a defect that fired on 297 of 300
/// access units of every stream we ship, on two other backends, for two milestones.
const LOWDELAY_640X480: &[u8] =
include_bytes!("../../pf-vkdecode/tests/data/lowdelay-640x480.h264");
/// Minimal H.264 access-unit splitter. The production wire delivers whole access
/// units, so pf-bitstream keeps its splitter test-only; this is the same rule —
/// a new AU begins at a non-VCL NALU following slices, or at a slice declaring
@@ -693,83 +707,311 @@ mod tests {
);
}
/// The decode target must never be a surface this same access unit READS.
/// What one walk of a stream through [`plan_to_va`] measured.
///
/// This is the question a slot ledger cannot answer, and it is why the caller
/// binds the setup surface instead of the conversion reading one out of a
/// slot-indexed table.
/// Every field is a count of ACCESS UNITS, so the four are directly comparable and
/// each is bounded by [`Self::converted`].
#[derive(Debug, Default)]
struct AliasWalk {
/// Access units planned and converted.
converted: usize,
/// The setup picture was assigned a slot this access unit's OWN removals had
/// just freed. `SlotMap::assign` takes the lowest free slot, so this is the
/// ordinary case rather than an edge one — and it is why a decode target read
/// out of a slot-indexed table would be the surface of the picture just
/// displayed.
inherited_a_just_freed_slot: usize,
/// This access unit's own `removed` list names a picture its `dpb_refs`
/// snapshot also names: 8.2.5's sliding window unmarked a reference in the very
/// unit whose C.4.5.3 bump evicted it. The aliasing PRECONDITION, and the shape
/// the vendored conformance vector never produces.
removed_and_referenced: usize,
/// The setup picture took the slot of a picture this same access unit READS.
/// This is the D3D11VA/Vulkan defect verbatim — `CurrPic` and a reference entry
/// resolving through one slot — and on those two backends the surface followed
/// the slot, so the submission aliased. Here the surface does not follow the
/// slot, which is what [`Self::aliased`] measures.
setup_took_a_read_pictures_slot: usize,
/// The submission names the decode target as one of its own references, in
/// `reference_frames` or in any slice's `RefPicList0`/`1`. Must be zero.
aliased: usize,
}
/// Drive `stream` through the planner and [`plan_to_va`], modelling the caller the
/// way the Linux rung is written, and count the four shapes above.
///
/// `SlotMap::assign` takes the LOWEST free slot, and a slot freed by this
/// access unit's own removals is free by the time the setup picture is
/// assigned. Measured on the vendored vector, that is not an edge case: the
/// setup picture inherits a just-freed slot on **225 of 250** access units.
/// A surface bound BY SLOT would therefore decode, on nine frames in ten,
/// into the surface still holding the picture that was just displayed — which
/// under zero-copy the consumer may still be sampling. Hence the pool model
/// this crate's callers use, and hence `setup_surface`.
///
/// The second half of the test is the reassurance that comes with it: given
/// the caller's contract (a surface bound to no live picture), the decode
/// target is never a surface the same access unit READS. That is checked
/// against both readable sets, which are not the same snapshot — `dpb_refs` is
/// taken after this AU's marking process, the per-slice lists before it.
#[test]
fn the_setup_picture_routinely_inherits_a_just_freed_slot() {
/// The model is one line and it is the whole contract: the decode target is a
/// surface that **is not in the table the conversion is handed**, and it enters
/// that table only after the conversion returns. `video_vaapi_native`'s
/// `the_low_delay_stream_never_hands_the_decoder_a_surface_it_is_predicting_from`
/// is the same walk driven through the REAL `Session` pool, which is what says the
/// rung honours the contract; this one says what the contract buys.
fn walk_for_aliasing(stream: &[u8]) -> AliasWalk {
use pf_bitstream::h264::H264Planner;
let aus = split_aus(TEST_25FPS_H264);
let mut planner = H264Planner::new();
let mut surfaces: Vec<u32> = Vec::new();
let mut slots: Option<SlotMap> = None;
let mut collisions = 0usize;
let mut first: Option<usize> = None;
let mut inherited = 0usize;
// Slot to surface — precisely `Session::surface_table()` on the Linux rung.
let mut table: Vec<u32> = Vec::new();
let mut out = AliasWalk::default();
for (index, au) in aus.iter().enumerate() {
let plan = planner.plan_au(au).expect("the clean vector plans");
for (index, au) in split_aus(stream).into_iter().enumerate() {
let plan = planner
.plan_au(au)
.unwrap_or_else(|e| panic!("AU {index}: this stream must plan, got {e:?}"));
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
surfaces.resize(map.capacity(), VA_INVALID_SURFACE);
// Which slots this AU's own removals will free — read BEFORE the
// conversion applies them, because afterwards the ledger has forgotten.
assert_eq!(
map.capacity(),
plan.picture.max_dpb_frames + 1,
"AU {index}: neither stream renegotiates its DPB depth mid-walk"
);
table.resize(map.capacity(), VA_INVALID_SURFACE);
if plan
.dpb
.removed
.iter()
.any(|id| plan.dpb_refs.iter().any(|r| r.id == *id))
{
out.removed_and_referenced += 1;
}
// Which slots this AU's removals will free, read BEFORE the conversion
// applies them — afterwards the ledger has forgotten.
let freed: Vec<u8> = plan
.dpb
.removed
.iter()
.filter_map(|id| map.slot_of(*id))
.collect();
// Ids start well away from slot indices and are never reused, so a stale or
// aliased reference shows up as a value rather than as a plausible-looking
// off-by-one and cannot hide behind a surface that happens to be right
// again. The assertion is the model's own precondition: a target the table
// already names would beg the question this walk exists to answer.
let setup_surface = SURFACE_BASE + index as u32;
let out = plan_to_va(&plan, au, map, &surfaces, setup_surface)
.expect("the clean vector converts");
surfaces[usize::from(out.setup_slot)] = setup_surface;
if freed.contains(&out.setup_slot) {
inherited += 1;
assert!(
!table.contains(&setup_surface),
"AU {index}: the model handed out a surface the table already names"
);
let displaced = table.clone();
let converted = plan_to_va(&plan, au, map, &table, setup_surface)
.unwrap_or_else(|e| panic!("AU {index}: conversion failed: {e}"));
table[usize::from(converted.setup_slot)] = setup_surface;
// Both readable sets, and they are not the same snapshot: `dpb_refs` is
// taken after this AU's marking process, the per-slice lists before it.
let named: Vec<u32> = converted
.pic_params
.reference_frames
.iter()
.chain(
converted
.slices
.iter()
.flat_map(|s| s.ref_pic_list0.iter().chain(s.ref_pic_list1.iter())),
)
.filter(|e| e.flags & VA_PICTURE_H264_INVALID == 0)
.map(|e| e.picture_id)
.collect();
if freed.contains(&converted.setup_slot) {
out.inherited_a_just_freed_slot += 1;
}
let curr = out.pic_params.curr_pic.picture_id;
let names =
|e: &VaPictureH264| e.flags & VA_PICTURE_H264_INVALID == 0 && e.picture_id == curr;
let read_by_this_au = out.pic_params.reference_frames.iter().any(names)
|| out.slices.iter().any(|s| {
s.ref_pic_list0.iter().any(names) || s.ref_pic_list1.iter().any(names)
});
if read_by_this_au {
collisions += 1;
first.get_or_insert(index);
let evicted_surface = displaced[usize::from(converted.setup_slot)];
if evicted_surface != VA_INVALID_SURFACE && named.contains(&evicted_surface) {
out.setup_took_a_read_pictures_slot += 1;
}
assert_eq!(
converted.pic_params.curr_pic.picture_id, setup_surface,
"AU {index}: the current picture must be the surface the caller bound"
);
if named.contains(&setup_surface) {
out.aliased += 1;
}
out.converted += 1;
}
// The measurement this design rests on. A floor rather than the exact
// count, so a planner change that shifts it by a frame does not fail —
// but one that made slot reuse RARE would, and would mean the doc above
// has stopped being true.
out
}
/// The setup picture routinely inherits a slot its own access unit just freed —
/// which is why the decode target is a PARAMETER and not `surfaces[setup_slot]`.
///
/// `SlotMap::assign` takes the LOWEST free slot, and a slot freed by this access
/// unit's own removals is free by the time the setup picture is assigned. Measured
/// on the vendored vector that is not an edge case: **225 of 250** access units. A
/// surface bound BY SLOT would therefore decode, on nine frames in ten, into the
/// surface still holding the picture that was just displayed — which under
/// zero-copy the consumer may still be sampling. Hence the pool model this crate's
/// callers use, and hence `setup_surface`.
///
/// ⚠ This test used to carry a second half asserting the decode target was never
/// also a reference. It was VACUOUS: the walk hands every picture its own
/// never-reused surface id, so distinct ids cannot collide and the assertion could
/// not fail whatever the conversion did. The real question needs a surface pool
/// that RECYCLES, and it is answered by the two tests below and by
/// `video_vaapi_native`'s walk through the real one.
#[test]
fn the_setup_picture_routinely_inherits_a_just_freed_slot() {
let walk = walk_for_aliasing(TEST_25FPS_H264);
assert_eq!(walk.converted, 250);
// A floor rather than the exact count, so a planner change that shifts it by a
// frame does not fail — but one that made slot reuse RARE would, and would mean
// the documentation citing this number has stopped being true.
assert!(
inherited > 200,
"the setup picture inherited a just-freed slot on only {inherited} of 250 access \
walk.inherited_a_just_freed_slot > 200,
"the setup picture inherited a just-freed slot on only {} of 250 access \
units the reason `setup_surface` is a parameter no longer holds, and the \
documentation that cites it needs re-measuring"
documentation that cites it needs re-measuring",
walk.inherited_a_just_freed_slot
);
}
/// The aliasing PRECONDITION, on both streams — the number that says the exemption
/// below is being tested by something rather than merely passing.
///
/// Two conditions have to coincide inside ONE access unit for a conversion that
/// releases eagerly to hand the decode target a picture it is predicting from: the
/// access unit must remove a picture, and that picture must still be in the
/// `dpb_refs` snapshot the reference lists are built from. Low-delay H.264 is
/// exactly what makes them coincide, and NVENC seals it by writing
/// `max_num_ref_frames = 3` ALONGSIDE `max_dec_frame_buffering = 3` — a DPB exactly
/// as deep as its reference count — while `max_num_reorder_frames = 0` means the
/// evicted picture has already been output and is therefore evictable at all.
///
/// The vendored conformance vector produces the shape ZERO times, which is why it
/// proved nothing on two other backends for two milestones. If that zero ever moves
/// the reasoning above is wrong and the 117 needs re-deriving before it means
/// anything.
#[test]
fn the_low_delay_stream_reassigns_slots_whose_pictures_it_still_reads() {
let vector = walk_for_aliasing(TEST_25FPS_H264);
assert_eq!(vector.converted, 250);
assert!(
vector.inherited_a_just_freed_slot > 0,
"no access unit of the vendored vector reused a freed slot, so the zeroes \
below would be empty for a reason that has nothing to do with the hazard"
);
assert_eq!(
collisions, 0,
"the decode target collided with a picture this access unit reads, on \
{collisions} of 250 (first at AU {first:?})"
vector.removed_and_referenced, 0,
"the vendored vector is supposed to be BLIND to this shape"
);
assert_eq!(
vector.setup_took_a_read_pictures_slot, 0,
"and therefore never to hand the setup picture a slot it still reads"
);
let lowdelay = walk_for_aliasing(LOWDELAY_640X480);
assert_eq!(lowdelay.converted, 120);
assert_eq!(
lowdelay.removed_and_referenced, 117,
"the low-delay stream must still exercise the aliasing precondition on \
nearly every access unit if this drops to zero the exemption below is no \
longer being TESTED by anything, whatever else still passes"
);
assert_eq!(
lowdelay.setup_took_a_read_pictures_slot, 117,
"and the slot really is handed straight back to the decode target: this is \
the D3D11VA/Vulkan defect, present here, and harmless only because the \
SURFACE does not follow the slot"
);
}
/// The exemption itself: no submission names its decode target as one of its own
/// references, on either stream.
///
/// This conversion still releases its whole `removed` list inline, exactly as the
/// two backends that had to grow a `release_after_decode` deferral once did. It is
/// safe doing so for one reason, and it is a property of the INTERFACE rather than
/// of any stream: `plan_to_va` never invents a surface. Every reference it can name
/// is read out of the `surfaces` table it was handed, so a decode target that is
/// not in that table cannot be named, whatever the ledger does with slots. A slot
/// is not a surface here; on DXVA it was.
///
/// ⚠ That makes this a statement about the CALLER's contract, so it is only half
/// the proof. The other half — that the Linux rung really does pick its decode
/// target from outside the table — cannot be made here, because the pool lives in
/// `pf-client-core`. It is
/// `video_vaapi_native`'s
/// `the_low_delay_stream_never_hands_the_decoder_a_surface_it_is_predicting_from`,
/// which drives this same stream through the real `Session`.
#[test]
fn no_submission_names_its_decode_target_as_one_of_its_own_references() {
for (name, walk) in [
("the vendored vector", walk_for_aliasing(TEST_25FPS_H264)),
("the low-delay stream", walk_for_aliasing(LOWDELAY_640X480)),
] {
assert_eq!(
walk.aliased, 0,
"{name}: {} of {} access units decode into a surface they predict from",
walk.aliased, walk.converted
);
}
}
/// A decode target the caller took from INSIDE the slot table is named as its own
/// reference — the counterfactual that gives the test above its teeth.
///
/// Without this, `aliased == 0` would be consistent with a conversion that could
/// never alias for reasons of its own, and a reader could not tell which. This
/// picks the target the way the two broken backends effectively did — the surface
/// sitting in the slot the setup picture is about to take — and shows the same walk
/// then aliases on 117 of 120 access units of the low-delay stream. So the walk can
/// see the defect; it does not see it because the contract holds.
#[test]
fn taking_the_decode_target_from_the_slot_table_aliases_on_the_low_delay_stream() {
use pf_bitstream::h264::H264Planner;
let mut planner = H264Planner::new();
let mut slots: Option<SlotMap> = None;
let mut table: Vec<u32> = Vec::new();
let (mut converted, mut aliased) = (0usize, 0usize);
for (index, au) in split_aus(LOWDELAY_640X480).into_iter().enumerate() {
let plan = planner.plan_au(au).expect("the low-delay stream plans");
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
table.resize(map.capacity(), VA_INVALID_SURFACE);
// The bug, modelled: convert first to learn the slot, then re-run the same
// access unit against the real ledger with the target read OUT of the
// table. Two passes only because the slot is not known until the conversion
// returns; the submission compared below is the second one.
//
// The probe's own `setup_surface` is arbitrary and deliberately so — the
// slot is chosen by `SlotMap::assign` from the ledger alone and no
// conversion consults the target to pick it, which is why one pass can
// stand in for the other.
let mut probe = map.clone();
let peek = plan_to_va(&plan, au, &mut probe, &table, SURFACE_BASE)
.expect("the low-delay stream converts");
let target = table[usize::from(peek.setup_slot)];
let target = if target == VA_INVALID_SURFACE {
SURFACE_BASE + index as u32
} else {
target
};
let out = plan_to_va(&plan, au, map, &table, target).expect("the same conversion");
table[usize::from(out.setup_slot)] = target;
let names = |e: &VaPictureH264| {
e.flags & VA_PICTURE_H264_INVALID == 0 && e.picture_id == target
};
if out.pic_params.reference_frames.iter().any(names)
|| out
.slices
.iter()
.any(|s| s.ref_pic_list0.iter().any(names) || s.ref_pic_list1.iter().any(names))
{
aliased += 1;
}
converted += 1;
}
assert_eq!(converted, 120);
assert_eq!(
aliased, 117,
"binding the decode target BY SLOT is supposed to reproduce the defect on \
this stream; if it no longer does, the exemption test above is passing for \
a reason nobody has checked"
);
}
+597 -1
View File
@@ -1,4 +1,5 @@
//! The libva decode buffer layouts for H.264, **hand-declared**.
//! The libva decode buffer layouts for H.264, **hand-declared** — plus the
//! codec-independent `VAImage` pair the test-only surface readback needs.
//!
//! There is no libva binding in this workspace and this crate deliberately does not
//! introduce one: it must compile and be tested on macOS and in the Linux container,
@@ -41,6 +42,24 @@
//! This crate never invents one: the conversion (`plan_to_va`) takes the caller's
//! slot → `VASurfaceID` table and indexes it, so the Linux layer owns surface
//! allocation and this half stays pure.
//!
//! # The image half, and why it is here at all
//!
//! [`VaImage`] and [`VaImageFormat`] are not decode buffers: they are what
//! `vaDeriveImage` (or `vaCreateImage` + `vaGetImage`) writes back when something
//! wants to READ a decoded surface on the CPU. Nothing on the production video path
//! does — the rung exports a DRM-PRIME dmabuf and the presenter samples it, which is
//! the zero-copy contract this project refuses to spend — so the only caller is the
//! frame-hash parity harness in `pf-client-core`'s `video_vaapi_native::parity`, which
//! exists solely under `#[cfg(test)]`.
//!
//! They live here for the same reason every other structure in this file does: the
//! harness must not force a `libva-dev` build dependency on a crate that compiles on
//! macOS and in the container. Declaring them costs nothing at runtime (nothing
//! constructs one outside a test) and lets the readback's geometry — the part that has
//! already cost this program a release, in the shape of a chroma plane read at the
//! DISPLAY height instead of the driver's reported offset — be unit-tested with no
//! device at all. That walk is [`pack_two_plane`].
/// `VA_INVALID_SURFACE` — what an unused `ReferenceFrames` / `RefPicList` entry
/// carries. Paired with [`VA_PICTURE_H264_INVALID`]; drivers key on the flag, but a
@@ -389,6 +408,346 @@ const _: () = {
assert!(offset_of!(VaSliceParameterBufferH264, va_reserved) == 3112);
};
// ---------------------------------------------------------------------------
// The image pair — a CPU-readable view of a decoded surface (module docs).
//
// ⚠ TEST-ONLY BY CONSTRUCTION. Nothing on the production video path maps a surface;
// these types exist so a parity harness can, without this crate growing a libva
// build dependency. `pack_two_plane` below is pure and is the only logic here.
// ---------------------------------------------------------------------------
/// `VA_LSB_FIRST` — the byte order every YUV format libva describes uses. Named
/// because [`VaImageFormat`] carries the field and a zero there is not a "left unset",
/// it is an invalid enumerator.
pub const VA_LSB_FIRST: u32 = 1;
/// `VA_MSB_FIRST` — measured beside it so the pair reads as an enumeration rather
/// than as one magic number.
pub const VA_MSB_FIRST: u32 = 2;
/// `VAImageFormat` — what a `VAImage` is in, and what `vaCreateImage` is asked for.
///
/// The RGB fields are dead weight for this crate's two formats (NV12 and P010) and
/// are declared anyway: they occupy bytes 12..32 and dropping them would shift
/// `va_reserved`, which is exactly the class of mistake the assertions below exist
/// to make a compile error.
#[repr(C)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct VaImageFormat {
pub fourcc: u32,
/// [`VA_LSB_FIRST`] or [`VA_MSB_FIRST`].
pub byte_order: u32,
pub bits_per_pixel: u32,
/// RGB only.
pub depth: u32,
pub red_mask: u32,
pub green_mask: u32,
pub blue_mask: u32,
pub alpha_mask: u32,
/// `va_reserved[VA_PADDING_LOW]` — "must be zero".
pub va_reserved: [u32; 4],
}
/// `VAImage` — the descriptor `vaDeriveImage` / `vaCreateImage` fills in.
///
/// ⚠ `width` and `height` are **`unsigned short`**, not `unsigned int`. That is the
/// one thing about this structure a reader would get wrong by counting 32-bit words:
/// every field after them sits two bytes earlier than the obvious arithmetic puts it,
/// which is why `data_size` is at 60 and not 64. Measured, not reasoned about.
///
/// `pitches` and `offsets` are per PLANE and are the driver's own — the chroma plane
/// begins at `offsets[1]`, which on a decode surface is nowhere near
/// `pitches[0] * display_height` because the surface is padded to the codec's granule.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaImage {
/// `VAImageID`, and what `vaGetImage` / `vaDestroyImage` are handed.
pub image_id: u32,
pub format: VaImageFormat,
/// `VABufferID` — the buffer `vaMapBuffer` returns the pixels of.
pub buf: u32,
pub width: u16,
pub height: u16,
/// The whole mapped extent, in bytes. Everything [`pack_two_plane`] reads is
/// bounds-checked against it.
pub data_size: u32,
pub num_planes: u32,
pub pitches: [u32; 3],
pub offsets: [u32; 3],
/// Palette fields, meaningless for YUV and declared for their bytes.
pub num_palette_entries: i32,
pub entry_bytes: i32,
pub component_order: [i8; 4],
/// `va_reserved[VA_PADDING_LOW]`.
pub va_reserved: [u32; 4],
}
impl VaImage {
/// An all-zero descriptor — what a caller hands `vaDeriveImage` to fill.
///
/// Zero rather than uninitialised on purpose: a failed derive leaves a descriptor
/// the caller still reads — to decide whether there is an image to destroy, and to
/// report what the driver DID hand back — and reading uninitialised bytes to do
/// that is undefined behaviour rather than a diagnostic.
pub const fn zeroed() -> VaImage {
VaImage {
image_id: 0,
format: VaImageFormat {
fourcc: 0,
byte_order: 0,
bits_per_pixel: 0,
depth: 0,
red_mask: 0,
green_mask: 0,
blue_mask: 0,
alpha_mask: 0,
va_reserved: [0; 4],
},
buf: 0,
width: 0,
height: 0,
data_size: 0,
num_planes: 0,
pitches: [0; 3],
offsets: [0; 3],
num_palette_entries: 0,
entry_bytes: 0,
component_order: [0; 4],
va_reserved: [0; 4],
}
}
}
/// Why a mapped image could not be read as the picture it was supposed to hold.
///
/// Every arm carries what the DRIVER said rather than a verdict, because the whole
/// point of this walk refusing instead of guessing is that the refusal names the
/// thing that has to be looked at next. A harness that quietly produced a short or
/// mis-strided buffer would compare hashes of garbage against libavcodec's and report
/// a decode defect that is not there.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImageReadError {
/// The caller asked for a format this walk does not describe. Only the two
/// two-plane YUV formats the surface pool is ever created with are supported.
UnsupportedFourcc { fourcc: u32 },
/// The image came back in a different format from the surface pool's — a driver
/// that substituted, which is precisely the "derive handed you something you
/// cannot interpret" case.
Fourcc { got: u32, want: u32 },
/// Fewer than two planes: a packed or opaque layout, not NV12/P010.
NotTwoPlane { planes: u32 },
/// The image is smaller than the region asked for.
TooSmall {
image: (u32, u32),
display: (u32, u32),
},
/// A row of the picture does not fit the plane's own pitch.
Pitch {
plane: usize,
pitch: u32,
need: usize,
},
/// A row would be read past the end of the mapped buffer.
OutOfBounds {
plane: usize,
row: u32,
at: usize,
end: usize,
mapped: usize,
},
}
impl std::fmt::Display for ImageReadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ImageReadError::UnsupportedFourcc { fourcc } => {
write!(f, "no two-plane layout for fourcc {}", fourcc_name(*fourcc))
}
ImageReadError::Fourcc { got, want } => write!(
f,
"the image is {} but the surface pool is {}",
fourcc_name(*got),
fourcc_name(*want)
),
ImageReadError::NotTwoPlane { planes } => {
write!(f, "the image has {planes} plane(s), not two")
}
ImageReadError::TooSmall { image, display } => write!(
f,
"the image is {}x{} but the picture is {}x{}",
image.0, image.1, display.0, display.1
),
ImageReadError::Pitch { plane, pitch, need } => write!(
f,
"plane {plane}'s pitch is {pitch} bytes, a row needs {need}"
),
ImageReadError::OutOfBounds {
plane,
row,
at,
end,
mapped,
} => write!(
f,
"plane {plane} row {row} spans {at}..{end} of a {mapped}-byte mapping"
),
}
}
}
impl std::error::Error for ImageReadError {}
/// A fourcc as its four characters, for a message a human can act on.
fn fourcc_name(fourcc: u32) -> String {
let bytes = fourcc.to_le_bytes();
match std::str::from_utf8(&bytes) {
Ok(s) if bytes.iter().all(|b| b.is_ascii_graphic()) => s.to_string(),
_ => format!("{fourcc:#010x}"),
}
}
/// How many bytes one tightly packed `display`-sized picture of `fourcc` occupies —
/// the layout every golden set in this program hashes.
///
/// `None` for a fourcc with no two-plane 4:2:0 layout here.
pub fn packed_len(display: (u32, u32), fourcc: u32) -> Option<usize> {
let bytes_per_sample = bytes_per_sample(fourcc)?;
let (w, h) = (display.0 as usize, display.1 as usize);
Some(w * bytes_per_sample * (h + h.div_ceil(2)))
}
/// One luma sample's size in bytes for the two formats the pool is ever built with.
fn bytes_per_sample(fourcc: u32) -> Option<usize> {
match fourcc {
crate::drm::VA_FOURCC_NV12 => Some(1),
// ⚠ P010 is 16 bits per sample with the ten meaningful bits in the HIGH end
// of each little-endian word. This walk moves bytes and never touches the
// alignment; a driver that handed back LSB-aligned samples would produce a
// buffer of exactly the right SIZE and the wrong content, which is a
// divergence the goldens catch and this function cannot.
crate::drm::VA_FOURCC_P010 => Some(2),
_ => None,
}
}
/// Read the `display`-sized picture out of a mapped `VAImage`, packed tightly — byte
/// for byte the layout `pf-vkdecode`'s golden files hash.
///
/// This is the whole of the readback that can be wrong without a device, so it is the
/// whole of what is worth testing without one. Three things it does deliberately:
///
/// * **The chroma plane starts at `offsets[1]`**, the driver's own number, never at
/// `pitches[0] * height`. A decode surface is padded to the codec's granule — 240
/// lines of HEVC live in a 256-line surface — so computing the offset from the
/// display height reads the tail of the luma padding as chroma and smears every
/// row. This project has already paid for that once on another rung.
/// * **Padding columns are dropped per row.** `pitches[0]` is the surface's stride,
/// which is wider than the picture; only `width * bytes_per_sample` bytes of each
/// row belong to the golden.
/// * **Every read is bounds-checked against the mapping the driver declared**, and a
/// failure is returned rather than clamped. A short mapping means the descriptor
/// and the buffer disagree, and no hash taken from it means anything.
///
/// `mapped` must be the buffer `vaMapBuffer` returned, of length
/// [`VaImage::data_size`]; the caller passes it as a slice so this function needs no
/// `unsafe` and can be driven from a plain array in a test.
pub fn pack_two_plane(
image: &VaImage,
mapped: &[u8],
display: (u32, u32),
fourcc: u32,
) -> Result<Vec<u8>, ImageReadError> {
let bytes_per_sample =
bytes_per_sample(fourcc).ok_or(ImageReadError::UnsupportedFourcc { fourcc })?;
if image.format.fourcc != fourcc {
return Err(ImageReadError::Fourcc {
got: image.format.fourcc,
want: fourcc,
});
}
if image.num_planes < 2 {
return Err(ImageReadError::NotTwoPlane {
planes: image.num_planes,
});
}
let (width, height) = display;
if u32::from(image.width) < width || u32::from(image.height) < height {
return Err(ImageReadError::TooSmall {
image: (u32::from(image.width), u32::from(image.height)),
display,
});
}
// One row of the picture, in both planes: 4:2:0 chroma is half the rows but
// interleaved (U,V) pairs, so a chroma row carries exactly as many BYTES as a
// luma row.
let row_bytes = width as usize * bytes_per_sample;
let rows = [height, height.div_ceil(2)];
let mut out = Vec::with_capacity(row_bytes * (rows[0] + rows[1]) as usize);
for (plane, plane_rows) in rows.iter().enumerate() {
let pitch = image.pitches[plane] as usize;
if pitch < row_bytes {
return Err(ImageReadError::Pitch {
plane,
pitch: image.pitches[plane],
need: row_bytes,
});
}
let base = image.offsets[plane] as usize;
for row in 0..*plane_rows {
let at = base + row as usize * pitch;
let end = at + row_bytes;
if end > mapped.len() {
return Err(ImageReadError::OutOfBounds {
plane,
row,
at,
end,
mapped: mapped.len(),
});
}
out.extend_from_slice(&mapped[at..end]);
}
}
Ok(out)
}
// ---------------------------------------------------------------------------
// Image layout proofs — the probe's output, pinned (libva 2.23.0-1ubuntu1,
// x86_64-linux-gnu, measured 2026-08-07 by `layout-probe.c`).
// ---------------------------------------------------------------------------
const _: () = {
use std::mem::offset_of;
use std::mem::size_of;
assert!(size_of::<VaImageFormat>() == 48);
assert!(offset_of!(VaImageFormat, fourcc) == 0);
assert!(offset_of!(VaImageFormat, byte_order) == 4);
assert!(offset_of!(VaImageFormat, bits_per_pixel) == 8);
assert!(offset_of!(VaImageFormat, depth) == 12);
assert!(offset_of!(VaImageFormat, red_mask) == 16);
assert!(offset_of!(VaImageFormat, green_mask) == 20);
assert!(offset_of!(VaImageFormat, blue_mask) == 24);
assert!(offset_of!(VaImageFormat, alpha_mask) == 28);
assert!(offset_of!(VaImageFormat, va_reserved) == 32);
// ⚠ `width`/`height` are 16-bit, which is why `data_size` is at 60 rather than
// at the 64 that counting 32-bit fields would give.
assert!(size_of::<VaImage>() == 120);
assert!(offset_of!(VaImage, image_id) == 0);
assert!(offset_of!(VaImage, format) == 4);
assert!(offset_of!(VaImage, buf) == 52);
assert!(offset_of!(VaImage, width) == 56);
assert!(offset_of!(VaImage, height) == 58);
assert!(offset_of!(VaImage, data_size) == 60);
assert!(offset_of!(VaImage, num_planes) == 64);
assert!(offset_of!(VaImage, pitches) == 68);
assert!(offset_of!(VaImage, offsets) == 80);
assert!(offset_of!(VaImage, num_palette_entries) == 92);
assert!(offset_of!(VaImage, entry_bytes) == 96);
assert!(offset_of!(VaImage, component_order) == 100);
assert!(offset_of!(VaImage, va_reserved) == 104);
};
#[cfg(test)]
mod tests {
use super::*;
@@ -585,4 +944,241 @@ mod tests {
.all(|e| e.picture_id == VA_INVALID_SURFACE));
assert_eq!(s.slice_data_flag, VA_SLICE_DATA_FLAG_ALL);
}
// -----------------------------------------------------------------------
// The image walk. Every one of these runs on macOS and in the container: the
// geometry is the half of a surface readback that can be wrong without a
// device, and it is the half that has been wrong before.
// -----------------------------------------------------------------------
/// A driver-shaped `VAImage`: a surface PADDED past the picture in both axes,
/// with the chroma plane where the driver puts it rather than where the display
/// height would.
// `_picture` is named at every call site so each test reads as the shape it is
// about, and is deliberately not consulted: the walk takes the picture size from
// its own argument, which is the whole point of the crop.
fn padded_image(
_picture: (u16, u16),
surface: (u16, u16),
pitch: u32,
fourcc: u32,
) -> (VaImage, Vec<u8>) {
let mut image = VaImage::zeroed();
image.format.fourcc = fourcc;
image.format.byte_order = VA_LSB_FIRST;
image.width = surface.0;
image.height = surface.1;
image.num_planes = 2;
image.pitches = [pitch, pitch, 0];
// The trap, expressed: chroma starts after the WHOLE padded luma plane.
image.offsets = [0, pitch * u32::from(surface.1), 0];
let total = pitch as usize * (surface.1 as usize + surface.1.div_ceil(2) as usize);
image.data_size = total as u32;
// Fill the mapping so every byte says where it came from: luma rows count
// 0.., chroma rows 128.., and the padding columns are 0xff so a walk that
// read them would produce something unmistakable.
let mut mapped = vec![0xffu8; total];
for y in 0..surface.1 as usize {
for x in 0..pitch as usize {
mapped[y * pitch as usize + x] = if x < surface.0 as usize {
(y % 100) as u8
} else {
0xff
};
}
}
let chroma = image.offsets[1] as usize;
for y in 0..surface.1.div_ceil(2) as usize {
for x in 0..pitch as usize {
mapped[chroma + y * pitch as usize + x] = if x < surface.0 as usize {
128 + (y % 100) as u8
} else {
0xff
};
}
}
(image, mapped)
}
#[test]
fn the_walk_crops_to_the_picture_and_takes_chroma_from_the_drivers_offset() {
// 320x240 picture in a 320x256 surface at a 384-byte pitch — HEVC's 128-line
// granule and a stride that is not the width, which is the everyday shape.
let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
let out = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12)
.expect("the walk must read a padded NV12 surface");
assert_eq!(out.len(), 320 * 240 + 320 * 120);
assert_eq!(
out.len(),
packed_len((320, 240), crate::drm::VA_FOURCC_NV12).unwrap()
);
// No padding byte reached the output: 0xff is only ever a padding column.
assert!(
!out.contains(&0xff),
"a padding column leaked into the packed picture"
);
// Luma row 3 is all 3s; chroma row 3 is all 131 — which is only true if the
// chroma plane was taken from offsets[1] and not from pitch * 240.
assert!(out[3 * 320..4 * 320].iter().all(|&b| b == 3));
let chroma = 320 * 240;
assert!(out[chroma + 3 * 320..chroma + 4 * 320]
.iter()
.all(|&b| b == 131));
}
#[test]
fn reading_chroma_at_the_display_height_would_have_been_caught() {
// The counterfactual for the assertion above: an image that claims chroma
// starts at `pitch * display_height` — the 1088-row smear — hands back
// LUMA padding rows where chroma belongs, and the walk cannot tell. So the
// guarantee is that the walk uses the DRIVER's offset, and this proves the
// two answers actually differ on the shape the drivers hand out (they would
// coincide on an unpadded surface, which is why the test above uses one that
// is padded in BOTH axes).
let (mut image, mapped) =
padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
let right = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12)
.expect("the driver's own offset reads");
image.offsets[1] = 384 * 240;
let wrong = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12)
.expect("the wrong offset also reads — that is the point");
assert_ne!(
right, wrong,
"chroma at the display height must differ from chroma at the driver's \
offset, or this walk's central claim is untestable"
);
}
#[test]
fn ten_bit_rows_are_twice_as_wide() {
// P010's samples are 16 bits, so a 320-sample row is 640 bytes and the packed
// picture is exactly twice an NV12 one. A walk that assumed one byte per
// sample would produce a half-width picture of the right total length for
// some other resolution, which is the kind of thing a length check alone
// misses.
let (image, mapped) = padded_image((320, 240), (320, 256), 768, crate::drm::VA_FOURCC_P010);
let out = pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_P010)
.expect("the walk must read a padded P010 surface");
assert_eq!(out.len(), 320 * 2 * 240 + 320 * 2 * 120);
assert_eq!(
out.len(),
packed_len((320, 240), crate::drm::VA_FOURCC_P010).unwrap()
);
assert_eq!(
out.len(),
2 * packed_len((320, 240), crate::drm::VA_FOURCC_NV12).unwrap()
);
}
#[test]
fn an_odd_height_keeps_its_half_chroma_row() {
let (image, mapped) = padded_image((16, 9), (16, 16), 32, crate::drm::VA_FOURCC_NV12);
let out = pack_two_plane(&image, &mapped, (16, 9), crate::drm::VA_FOURCC_NV12)
.expect("an odd height still reads");
assert_eq!(out.len(), 16 * 9 + 16 * 5, "9 luma rows, 5 chroma rows");
}
#[test]
fn a_substituted_format_is_refused_rather_than_reinterpreted() {
let (mut image, mapped) =
padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
image.format.fourcc = crate::drm::VA_FOURCC_P010;
assert_eq!(
pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12),
Err(ImageReadError::Fourcc {
got: crate::drm::VA_FOURCC_P010,
want: crate::drm::VA_FOURCC_NV12
})
);
}
#[test]
fn a_packed_or_opaque_image_is_refused() {
let (mut image, mapped) =
padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
image.num_planes = 1;
assert_eq!(
pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12),
Err(ImageReadError::NotTwoPlane { planes: 1 })
);
}
#[test]
fn an_image_smaller_than_the_picture_is_refused() {
let (image, mapped) = padded_image((320, 240), (320, 240), 384, crate::drm::VA_FOURCC_NV12);
assert_eq!(
pack_two_plane(&image, &mapped, (321, 240), crate::drm::VA_FOURCC_NV12),
Err(ImageReadError::TooSmall {
image: (320, 240),
display: (321, 240)
})
);
}
#[test]
fn a_pitch_narrower_than_a_row_is_refused() {
let (mut image, mapped) =
padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
image.pitches[1] = 16;
assert_eq!(
pack_two_plane(&image, &mapped, (320, 240), crate::drm::VA_FOURCC_NV12),
Err(ImageReadError::Pitch {
plane: 1,
pitch: 16,
need: 320
})
);
}
#[test]
fn a_mapping_shorter_than_the_descriptor_claims_is_refused_not_truncated() {
// The failure mode that matters most: a short read must NOT silently produce
// a shorter picture, because its hash would then be a hash of something the
// decoder never wrote.
let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
// One byte short of the LAST chroma row the picture needs. Cutting the tail
// of the allocation would not do it: the surface is padded past the picture,
// so there is slack after the last row this walk reads — which is itself
// worth pinning, since it is why a `data_size` check alone would not catch a
// driver whose offsets point outside its buffer.
let last_row_end = image.offsets[1] as usize + 119 * image.pitches[1] as usize + 320;
assert!(
last_row_end < mapped.len(),
"the padded surface must have slack after the picture's last chroma row"
);
let err = pack_two_plane(
&image,
&mapped[..last_row_end - 1],
(320, 240),
crate::drm::VA_FOURCC_NV12,
)
.expect_err("a short mapping must be refused");
assert!(
matches!(
err,
ImageReadError::OutOfBounds {
plane: 1,
row: 119,
..
}
),
"expected the last chroma row to be refused, got {err}"
);
}
#[test]
fn an_unknown_fourcc_has_no_packed_length_and_no_walk() {
assert_eq!(
packed_len((320, 240), 0x3132_3449),
None,
"I421 is not ours"
);
let (image, mapped) = padded_image((320, 240), (320, 256), 384, crate::drm::VA_FOURCC_NV12);
assert_eq!(
pack_two_plane(&image, &mapped, (320, 240), 0x3132_3449),
Err(ImageReadError::UnsupportedFourcc {
fourcc: 0x3132_3449
})
);
}
}
+182 -153
View File
@@ -822,175 +822,204 @@ impl VkH264Decoder {
}
let vk_plan = vk_plan.expect("the rebuilt session matches its own plan");
let state = self.state.as_mut().expect("ensured above");
// The per-AU active-reference gate: the session was created with
// maxActiveReferencePictures; binding more in one decode op would be a
// silent VUID violation on the drivers that matter most.
let max_active = state.session.config.max_active_references as usize;
if vk_plan.refs.len() > max_active {
return Err(VkDecodeError::Unsupported(format!(
"AU references {} pictures, session allows {max_active} active references",
vk_plan.refs.len()
)));
}
// Coincide binding sync: slots the planner released no longer bind their
// images (the pictures may still be pending/held — untouched), and the
// setup slot's PREVIOUS binding is cleared before it binds fresh.
let setup = usize::from(vk_plan.setup_slot);
if state.dpb.is_none() {
let mut held = vec![false; state.slot_image.len()];
for (slot, _id) in state.slots.held() {
held[usize::from(slot)] = true;
// Everything from here to the deferred release below is ONE unit of ledger
// work. `plan_to_vk` has already committed this AU's setup assignment and
// handed back the removals it deliberately did NOT apply
// (`DecodePlanVk::release_after_decode`); until those are applied the slot
// map holds one picture too many. A `?` anywhere in the region would skip
// them and leak a slot per failed AU — four `?`s and three early `return`s
// could — so the region's Result is HELD and the release runs either way.
let submitted = (|| -> Result<(), VkDecodeError> {
let state = self.state.as_mut().expect("ensured above");
// The per-AU active-reference gate: the session was created with
// maxActiveReferencePictures; binding more in one decode op would be a
// silent VUID violation on the drivers that matter most.
let max_active = state.session.config.max_active_references as usize;
if vk_plan.refs.len() > max_active {
return Err(VkDecodeError::Unsupported(format!(
"AU references {} pictures, session allows {max_active} active references",
vk_plan.refs.len()
)));
}
for (slot, binding) in state.slot_image.iter_mut().enumerate() {
if let Some(picture) = *binding {
if !held[slot] || slot == setup {
state.pool.pictures[picture].bound = false;
*binding = None;
// Coincide binding sync: slots the planner released no longer bind their
// images (the pictures may still be pending/held — untouched), and the
// setup slot's PREVIOUS binding is cleared before it binds fresh.
let setup = usize::from(vk_plan.setup_slot);
if state.dpb.is_none() {
let mut held = vec![false; state.slot_image.len()];
for (slot, _id) in state.slots.held() {
held[usize::from(slot)] = true;
}
for (slot, binding) in state.slot_image.iter_mut().enumerate() {
if let Some(picture) = *binding {
if !held[slot] || slot == setup {
state.pool.pictures[picture].bound = false;
*binding = None;
}
}
}
}
}
// The decode target: a FREE pool image (never one a consumer holds — the
// whole point of the pool model). Exhaustion means the consumer owes
// more than HOLD_HEADROOM releases; no wait can free an image here.
let Some(dst) = state.pool.free_index() else {
debug!(
held = state.pool.held_total(),
"picture pool exhausted — release_frame owed"
);
return Err(VkDecodeError::NoFreeSlot);
};
// The decode target: a FREE pool image (never one a consumer holds — the
// whole point of the pool model). Exhaustion means the consumer owes
// more than HOLD_HEADROOM releases; no wait can free an image here.
let Some(dst) = state.pool.free_index() else {
debug!(
held = state.pool.held_total(),
"picture pool exhausted — release_frame owed"
);
return Err(VkDecodeError::NoFreeSlot);
};
// Cross-queue waits (the AVVkFrame contract): the dst image's last known
// timeline value (covers a presenter write-back after release), plus —
// coincide mode — every referenced image's value, so reference reads
// order after any presenter layout restore already reported back.
let mut waits: Vec<(vk::Semaphore, u64)> = Vec::new();
{
let dst_pic = &state.pool.pictures[dst];
if dst_pic.value > 0 {
waits.push((dst_pic.semaphore, dst_pic.value));
// Cross-queue waits (the AVVkFrame contract): the dst image's last known
// timeline value (covers a presenter write-back after release), plus —
// coincide mode — every referenced image's value, so reference reads
// order after any presenter layout restore already reported back.
let mut waits: Vec<(vk::Semaphore, u64)> = Vec::new();
{
let dst_pic = &state.pool.pictures[dst];
if dst_pic.value > 0 {
waits.push((dst_pic.semaphore, dst_pic.value));
}
}
}
if state.dpb.is_none() {
for r in &vk_plan.refs {
if let Some(picture) = state.slot_image[usize::from(r.slot)] {
let pic = &state.pool.pictures[picture];
if pic.value > 0 && !waits.iter().any(|(sem, _)| *sem == pic.semaphore) {
waits.push((pic.semaphore, pic.value));
if state.dpb.is_none() {
for r in &vk_plan.refs {
if let Some(picture) = state.slot_image[usize::from(r.slot)] {
let pic = &state.pool.pictures[picture];
if pic.value > 0 && !waits.iter().any(|(sem, _)| *sem == pic.semaphore) {
waits.push((pic.semaphore, pic.value));
}
}
}
}
}
let signal_value = state.pool.pictures[dst].value + 1;
let signal_value = state.pool.pictures[dst].value + 1;
// Command buffer + query slot for this submission.
let submission = state.submitted;
let cmd_index = (submission % state.ops.cmds.len() as u64) as usize;
if let Some((sem, value)) = state.cmd_marks[cmd_index] {
// SAFETY: live device; the token is a pool image's semaphore.
unsafe { wait_timeline(self.dev.ash(), sem, value, "command buffer reuse")? };
}
let query_index = (submission % u64::from(state.ops.query_count)) as u32;
// Command buffer + query slot for this submission.
let submission = state.submitted;
let cmd_index = (submission % state.ops.cmds.len() as u64) as usize;
if let Some((sem, value)) = state.cmd_marks[cmd_index] {
// SAFETY: live device; the token is a pool image's semaphore.
unsafe { wait_timeline(self.dev.ash(), sem, value, "command buffer reuse")? };
}
let query_index = (submission % u64::from(state.ops.query_count)) as u32;
// Upload the AU (recycles/grows against submission-completion tokens).
let device = self.dev.ash().clone();
let mut poll = |token: &(vk::Semaphore, u64)| -> Result<bool, VkDecodeError> {
// SAFETY: live device; the token's semaphore is a pool semaphore.
let current = unsafe { device.get_semaphore_counter_value(token.0) }
.map_err(VkDecodeError::from)?;
Ok(current >= token.1)
};
let device2 = self.dev.ash().clone();
let mut wait = |token: &(vk::Semaphore, u64)| -> Result<(), VkDecodeError> {
// SAFETY: as above.
unsafe { wait_timeline(&device2, token.0, token.1, "bitstream slot drain") }
};
// The bitstream buffer carries the SLICE NALUs only, concatenated — a
// real AU opens with AUD/SEI (and, at IDRs, SPS/PPS) NALUs, and feeding
// those to the VCN firmware inside the decode range HANGS it (the .25
// `vcn_unified_0 ring timeout`; FFmpeg feeds slices-only for the same
// reason). `pack_slices` rebases the offsets into the packed buffer AND
// normalises each slice's Annex-B prefix to three bytes — the two go
// together by construction, see `crate::ring::three_byte_prefix`.
let plan_segments: Vec<std::ops::Range<usize>> =
plan.slices.iter().map(|s| s.data.clone()).collect();
let Some(packed) = pack_slices(au, &plan_segments) else {
return Err(VkDecodeError::Unsupported(
"packed slice data exceeds the u32 offsets Vulkan submits".into(),
));
};
let slice_offsets = packed.offsets;
// SAFETY: live device; the segments are the plan's own in-bounds slice
// ranges (narrowed by the prefix normalisation, so still in bounds); every
// pending token is the completion signal of the submission that consumed
// the slot.
let upload = unsafe {
// Upload the AU (recycles/grows against submission-completion tokens).
let device = self.dev.ash().clone();
let mut poll = |token: &(vk::Semaphore, u64)| -> Result<bool, VkDecodeError> {
// SAFETY: live device; the token's semaphore is a pool semaphore.
let current = unsafe { device.get_semaphore_counter_value(token.0) }
.map_err(VkDecodeError::from)?;
Ok(current >= token.1)
};
let device2 = self.dev.ash().clone();
let mut wait = |token: &(vk::Semaphore, u64)| -> Result<(), VkDecodeError> {
// SAFETY: as above.
unsafe { wait_timeline(&device2, token.0, token.1, "bitstream slot drain") }
};
// The bitstream buffer carries the SLICE NALUs only, concatenated — a
// real AU opens with AUD/SEI (and, at IDRs, SPS/PPS) NALUs, and feeding
// those to the VCN firmware inside the decode range HANGS it (the .25
// `vcn_unified_0 ring timeout`; FFmpeg feeds slices-only for the same
// reason). `pack_slices` rebases the offsets into the packed buffer AND
// normalises each slice's Annex-B prefix to three bytes — the two go
// together by construction, see `crate::ring::three_byte_prefix`.
let plan_segments: Vec<std::ops::Range<usize>> =
plan.slices.iter().map(|s| s.data.clone()).collect();
let Some(packed) = pack_slices(au, &plan_segments) else {
return Err(VkDecodeError::Unsupported(
"packed slice data exceeds the u32 offsets Vulkan submits".into(),
));
};
let slice_offsets = packed.offsets;
// SAFETY: live device; the segments are the plan's own in-bounds slice
// ranges (narrowed by the prefix normalisation, so still in bounds); every
// pending token is the completion signal of the submission that consumed
// the slot.
let upload = unsafe {
state
.ring
.upload(&self.dev, au, &packed.segments, &mut poll, &mut wait)?
};
// Record + submit, signalling the dst image's next timeline value.
// SAFETY: live device; every handle recorded below belongs to this
// session generation, and the packed slices sit uploaded in the ring slot.
unsafe {
record_and_submit(
&self.dev,
&*self.lock,
state,
&vk_plan,
&slice_offsets,
&upload,
dst,
cmd_index,
query_index,
&waits,
signal_value,
)?;
}
// Post-submit bookkeeping.
let dst_sem = state.pool.pictures[dst].semaphore;
state.pool.pictures[dst].value = signal_value;
state.pool.pictures[dst].pending = true;
if state.dpb.is_none() {
state.pool.pictures[dst].bound = true;
state.slot_image[setup] = Some(dst);
}
state.cmd_marks[cmd_index] = Some((dst_sem, signal_value));
state.query_marks[query_index as usize] = submission;
state.submitted += 1;
state.last_submit = Some((dst_sem, signal_value));
state
.ring
.upload(&self.dev, au, &packed.segments, &mut poll, &mut wait)?
};
.pending
.set_pending(upload.slot, (dst_sem, signal_value));
// Record + submit, signalling the dst image's next timeline value.
// SAFETY: live device; every handle recorded below belongs to this
// session generation, and the packed slices sit uploaded in the ring slot.
unsafe {
record_and_submit(
&self.dev,
&*self.lock,
state,
&vk_plan,
&slice_offsets,
&upload,
dst,
cmd_index,
query_index,
&waits,
signal_value,
)?;
// Refresh the per-slot reference cache from this AU's facts.
state.slot_refs[setup] = Some(vk_plan.setup_ref);
for r in &vk_plan.refs {
state.slot_refs[usize::from(r.slot)] = Some(r.std);
}
self.pending.insert(
vk_plan.setup_id,
PendingPic {
image: dst,
submission,
query_slot: query_index,
timeline_value: signal_value,
crop: plan.picture.display_crop,
colour: plan.picture.colour,
poc: plan.picture.pic_order_cnt,
is_idr: plan.picture.is_idr,
recovery,
decode_order,
},
);
Ok(())
})();
// The slots this AU's own 8.2.5 marking retired while the decode op still
// BOUND them (`DecodePlanVk::release_after_decode`). Held through the
// conversion, the coincide binding sync and the submission, so none of the
// three could take them; freed now that the op is recorded, so the next AU
// may have them. Their images stay pinned by `bound` until that AU's sync,
// the same one-frame grace every other released slot's image gets.
//
// This runs on the failure paths too, and must: the removals are the
// planner's verdict on pictures that left the DPB, which nothing this AU
// does can undo.
if let Some(state) = self.state.as_mut() {
for &id in &vk_plan.release_after_decode {
if !state.slots.release(id) {
trace!(id, "deferred release of an id the slot map no longer holds");
}
}
}
// Post-submit bookkeeping.
let dst_sem = state.pool.pictures[dst].semaphore;
state.pool.pictures[dst].value = signal_value;
state.pool.pictures[dst].pending = true;
if state.dpb.is_none() {
state.pool.pictures[dst].bound = true;
state.slot_image[setup] = Some(dst);
}
state.cmd_marks[cmd_index] = Some((dst_sem, signal_value));
state.query_marks[query_index as usize] = submission;
state.submitted += 1;
state.last_submit = Some((dst_sem, signal_value));
state
.ring
.pending
.set_pending(upload.slot, (dst_sem, signal_value));
// Refresh the per-slot reference cache from this AU's facts.
state.slot_refs[setup] = Some(vk_plan.setup_ref);
for r in &vk_plan.refs {
state.slot_refs[usize::from(r.slot)] = Some(r.std);
}
self.pending.insert(
vk_plan.setup_id,
PendingPic {
image: dst,
submission,
query_slot: query_index,
timeline_value: signal_value,
crop: plan.picture.display_crop,
colour: plan.picture.colour,
poc: plan.picture.pic_order_cnt,
is_idr: plan.picture.is_idr,
recovery,
decode_order,
},
);
submitted?;
// The plan's DPB verdicts over the pending map: outputs become ready
// frames (their images move pending → held until released);
+7 -1
View File
@@ -32,6 +32,12 @@ use crate::{Av1PlanWarning, H265PlanWarning, PlanWarning};
/// full (the plan holds the pre-rebase 8.2.1 values; later AUs reference the
/// rebased ones).
///
/// `LevelDerivedDpb` does not either: the picture is intact and fully planned. It
/// reports that the SPS never declared its DPB depth, so the plan had to size from
/// A.3.1's level ceiling and the result will not fit a mainstream slot pool — a
/// property of the STREAM's signalling, which the decoder answers by failing to open
/// a session, not by showing a damaged frame.
///
/// Written as an EXHAUSTIVE match with no wildcard, deliberately. A `matches!` (or
/// a `_ => false`) makes "damage" the opt-in and silence the default, so a
/// `PlanWarning` added later — by definition one nobody here has classified —
@@ -43,7 +49,7 @@ pub fn is_integrity_warning(w: &PlanWarning) -> bool {
PlanWarning::FrameNumGap { .. }
| PlanWarning::MissingReference { .. }
| PlanWarning::TruncatedAu { .. } => true,
PlanWarning::Mmco5Rebase => false,
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
}
}
+136 -32
View File
@@ -12,7 +12,6 @@ use ash::vk::native as hh;
use pf_bitstream::h264::AuPlan;
use pf_bitstream::h264::PicId;
use pf_bitstream::h264::RefPic;
use tracing::trace;
use crate::slots::SlotError;
use crate::slots::SlotMap;
@@ -56,6 +55,35 @@ pub struct DecodePlanVk {
pub setup_is_reference: bool,
/// The unique referenced pictures across all slices, in first-appearance order.
pub refs: Vec<VkRef>,
/// Slots this access unit's own end-of-picture bookkeeping retires while the
/// decode op still BINDS them. Release them once that op is recorded — never
/// inside the conversion, and never dropped.
///
/// The H.264 twin of [`crate::pic_av1::DecodePlanVkAv1::release_after_decode`],
/// and it exists for exactly the same reason: [`SlotMap::assign`] takes the lowest
/// free slot, so a release here hands the setup assignment two lines later the
/// slot a reference of this very AU still occupies. `pf_bitstream`'s `H264Planner`
/// snapshots `dpb_refs` in `begin_picture`, BEFORE `finish_picture` runs 8.2.5's
/// marking and C.4.5.3's bump, so a picture the sliding window unmarks and the
/// bump evicts is in both `dpb_refs` and `dpb.removed` for one AU — which needs
/// the eviction to be of an already-OUTPUT picture, i.e. low-delay H.264.
///
/// **Measured 2026-08-07 on this rung as well as the DXVA one:** 297 of 300 AUs of
/// every stream a punktfunk host emits, at 720p, 1080p and 2160p alike. See
/// [`pf_dxvadec::pic::DecodePlanDxva::release_after_decode`]'s docs for the full
/// measurement and for why `test-25fps.h264` measures zero.
///
/// Both of this rung's DPB modes break on it, differently and neither loudly:
/// in DISTINCT mode `slot_view` hands the aliased reference the same DPB array
/// layer the setup writes, a read-write alias of one subresource; in COINCIDE mode
/// the binding sync clears `slot_image[setup]` (it is the setup slot now) and the
/// reference resolves to no bound image at all, dropping out of `pReferenceSlots`
/// with a `trace!` and nothing else.
///
/// The caller must apply these on its FAILURE paths too. They are slot-ledger
/// bookkeeping the planner already committed, not something this AU's fate can
/// undo — dropping them leaks a slot per AU and reaches `SlotError::Full`.
pub release_after_decode: Vec<PicId>,
}
/// Conversion failures. Stream damage never lands here — pf-bitstream degrades it to
@@ -162,11 +190,14 @@ fn ref_info(rp: &RefPic) -> hh::StdVideoDecodeH264ReferenceInfo {
/// reference, e.g. the sliding window dropping the oldest short-term reference,
/// so `removed` must not be applied before the lists are mapped;
/// 3. slice offsets are validated (read-only);
/// 4. `removed` is applied — removals were real regardless of this AU's fate — and
/// the setup slot is assigned last (its failures are caller bugs; nothing is ever
/// half-applied). Released slots become assignable to later pictures; keeping the
/// underlying images alive until in-flight decodes complete is WP-B's
/// synchronization, not this map's.
/// 4. the setup slot is assigned last (its failures are caller bugs; nothing is ever
/// half-applied). `removed` is NOT applied here — it leaves as
/// [`DecodePlanVk::release_after_decode`] for the caller to apply once the decode
/// op is recorded, because applying it now would give the assignment back a slot
/// this AU's own references occupy (that field's docs carry the measurement).
/// Released slots become assignable to later pictures; keeping the underlying
/// images alive until in-flight decodes complete is WP-B's synchronization, not
/// this map's.
pub fn plan_to_vk(
plan: &AuPlan,
slots: &mut SlotMap,
@@ -276,24 +307,26 @@ pub fn plan_to_vk(
);
}
// Mutations LAST, after every fallible step above (fn docs). Removals first —
// they were real regardless of this AU's fate — then the setup assignment.
// Mutations LAST, after every fallible step above (fn docs).
//
// The removals are handed back rather than applied: releasing one here returns
// its slot to the setup assignment below, and this AU's own references sit in
// those slots (`DecodePlanVk::release_after_decode`).
//
// The AU's own picture can itself appear in `removed`: a non-reference picture
// with no free frame buffer bypasses the DPB and is stored-and-evicted within
// one plan. Its slot must still exist for the decode itself, so it is assigned
// here and released right after — see `DecodePlanVk::setup_is_reference`.
// here and released right after — see `DecodePlanVk::setup_is_reference`. That
// is the one removal that is NOT deferred: handing the caller the slot being
// decoded into is the very aliasing the deferral exists to prevent.
let setup_evicted = plan.dpb.removed.contains(&setup_id);
for &id in &plan.dpb.removed {
if id == setup_id {
continue;
}
if !slots.release(id) {
// Tolerated but never silent: reachable only when the caller skipped
// feeding an AU's plan through this map.
trace!(id, "DpbUpdate removed an id this SlotMap never assigned");
}
}
let release_after_decode: Vec<PicId> = plan
.dpb
.removed
.iter()
.copied()
.filter(|id| *id != setup_id)
.collect();
let setup_slot = slots.assign(setup_id)?;
if setup_evicted {
slots.release(setup_id);
@@ -307,6 +340,7 @@ pub fn plan_to_vk(
setup_id,
setup_is_reference: pic.is_reference,
refs,
release_after_decode,
})
}
@@ -398,7 +432,10 @@ mod tests {
});
let vk = plan_to_vk(&plan, slots, 0).expect("the clean vector converts");
// Binding sync: released slots unbind; the setup slot rebinds fresh.
// Binding sync: released slots unbind; the setup slot rebinds fresh. The
// deferred releases have deliberately NOT run yet — that is the whole
// point of `DecodePlanVk::release_after_decode`, and doing it in the wrong
// order here would unbind the images this AU's own references read.
let setup = usize::from(vk.setup_slot);
let mut held_slots = vec![false; slot_image.len()];
for (slot, _id) in slots.held() {
@@ -425,6 +462,12 @@ mod tests {
slot_image[setup] = Some(dst);
pending.insert(vk.setup_id, dst);
// Post-submit: the slots the conversion held back, exactly where
// `Decoder::decode` applies them.
for id in &vk.release_after_decode {
assert!(slots.release(*id), "a deferred id held no slot");
}
// Settle: outputs deliver to the consumer; removed-never-output free.
for id in &plan.dpb.outputs {
if let Some(picture) = pending.remove(id) {
@@ -522,10 +565,28 @@ mod tests {
);
// Mirror the map's bookkeeping: record the new picture, drop the removed.
//
// The removals are dropped only after the deferred releases run, because
// that is when the MAP drops them — before that the conversion is still
// holding them so this AU's submission can name their slots
// (`DecodePlanVk::release_after_decode`).
let stored = plan.dpb.stored.unwrap();
assert_eq!(vk.setup_id, stored);
assert_eq!(vk.setup_is_reference, plan.picture.is_reference);
held.insert(stored, vk.setup_slot);
assert_eq!(
vk.release_after_decode,
plan.dpb
.removed
.iter()
.copied()
.filter(|id| *id != stored)
.collect::<Vec<_>>(),
"the deferral is the plan's whole `removed` list less the stored id"
);
for id in &vk.release_after_decode {
assert!(slots.release(*id), "a deferred id held no slot");
}
for id in &plan.dpb.removed {
held.remove(id);
}
@@ -799,13 +860,22 @@ mod tests {
fn a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image() {
// Depth-1 DPB (Level 1 at 320x240 ⇒ max_dpb_frames 1, capacity 2): every
// stored P evicts the previous picture, and that picture's id lands in
// BOTH `outputs` and `removed` of the SAME plan — so `plan_to_vk` frees
// the evicted slot and immediately re-assigns it as this AU's setup.
// That SLOT reuse is fine and expected; the picture-pool model's whole
// point is that the re-activated slot binds a DIFFERENT free image, so
// the delivered picture's image is never the new decode target while the
// consumer holds it (the HIGH overwrite bug of the adversarial round,
// and the .25 field failure's class).
// BOTH `outputs` and `removed` of the SAME plan.
//
// ⚠ This test used to assert that `plan_to_vk` freed the evicted slot and
// re-assigned it as THIS AU's setup, calling that "the planner's normal
// behaviour". It was not: AU1 is a P picture that REFERENCES the picture it
// was evicting, so the submission named one slot as both `pSetupReferenceSlot`
// and a reference — a decode into the surface being predicted from. The same
// defect the AV1 rung was fixed for on 2026-08-07, authored here in miniature
// and asserted as correct. `DecodePlanVk::release_after_decode` is the fix, and
// this depth-1 stream is its tightest possible case: capacity 2, so the setup
// has exactly one slot to go to and it is the spare.
//
// What the test still proves, and what it was really written for, is the pool
// decoupling: the delivered picture's IMAGE is never the new decode target
// while the consumer holds it (the HIGH overwrite bug of the adversarial
// round, and the .25 field failure's class).
let sps = SpsBuilder::new()
.seq_parameter_set_id(0)
.profile_idc(Profile::Main)
@@ -851,16 +921,39 @@ mod tests {
.unwrap();
assert!(p1.dpb.outputs.contains(&vk0.setup_id) && p1.dpb.removed.contains(&vk0.setup_id));
let vk1 = plan_to_vk(&p1, &mut slots, 0).unwrap();
assert_eq!(
// AU1 REFERENCES the very picture it evicts — so the eviction is deferred and
// the setup goes to the spare slot instead. Without the deferral these two
// assertions are what fails, and they are the whole defect in two lines.
assert!(
vk1.refs.iter().any(|r| r.id == vk0.setup_id),
"AU1 must reference the picture it evicts, or this proves nothing"
);
assert_ne!(
vk1.setup_slot, vk0.setup_slot,
"slot reuse across the bump is the planner's normal behaviour"
"the setup must not take the slot of a picture this AU still references"
);
for r in &vk1.refs {
assert_ne!(r.slot, vk1.setup_slot, "a reference aliases the setup slot");
}
assert_eq!(
vk1.release_after_decode,
vec![vk0.setup_id],
"the eviction is handed back, not applied"
);
// Binding sync: the re-activated slot drops its old binding; picture 0's
// image is now delivered to the consumer (held), NOT freed.
// Binding sync: the setup slot is fresh, so nothing is unbound for it;
// picture 0's image is delivered to the consumer (held), NOT freed. Its slot
// is still HELD at this point — which is exactly what keeps its image bound
// while the decode op reads it as a reference.
assert!(
slots
.held()
.any(|(slot, id)| slot == vk0.setup_slot && id == vk0.setup_id),
"the referenced picture must still hold its slot through the submission"
);
bound[img0] = false;
held[img0] += 1; // outputs → delivered, consumer holds it
slot_image[usize::from(vk1.setup_slot)] = None;
// The pool hands the re-activated slot a FRESH image — never image 0.
let img1 = free(&bound, &held).expect("headroom guarantees a free image");
@@ -872,6 +965,17 @@ mod tests {
bound[img1] = true;
slot_image[usize::from(vk1.setup_slot)] = Some(img1);
// Post-submit: the deferred release lands, and NOW the evicted slot is free —
// a picture later than this submission may have it, which is the only thing
// the deferral ever postponed.
for id in &vk1.release_after_decode {
assert!(slots.release(*id));
}
assert!(
!slots.held().any(|(slot, _)| slot == vk0.setup_slot),
"the deferred release must actually free the slot"
);
// Once the consumer releases frame 0, image 0 returns to the free list.
held[img0] -= 1;
assert_eq!(free(&bound, &held), Some(img0));
+9 -6
View File
@@ -834,12 +834,15 @@ mod tests {
/// AV1 applies `refresh_frame_flags` after decoding (7.20), so `ref_frame_idx`
/// resolves against the store as it stood BEFORE the frame. Cycling eight slots
/// in a low-delay stream therefore means almost every frame displaces something
/// it is reading: **268 of this vector's 274 frames**, first at frame 6. The
/// H.264 and H.265 planners can produce the same shape — `plan_to_vk`'s own
/// docs name the sliding window evicting a picture the slices reference — but
/// neither vendored vector ever does it (measured: zero on the 250-AU H.264
/// clip), which is why the hole survived two hardware-proven codecs and opened
/// on the first AV1 frame that was not a key frame's neighbour.
/// it is reading: **268 of this vector's 274 frames**, first at frame 6.
///
/// The H.264 planner produces the same shape and the vendored vector never does
/// it (measured: zero on the 250-AU clip), which is why the hole survived a codec
/// believed hardware-proven and opened here first. That zero was a fact about the
/// vector: on a low-delay host stream H.264 aliases on 117 of 120 access units,
/// and `plan_to_vk` now carries the same deferral
/// ([`crate::pic::DecodePlanVk::release_after_decode`]). H.265 does not need one —
/// `H265Planner` snapshots `dpb_refs` after `decode_rps`.
#[test]
fn a_reference_this_frame_displaces_keeps_its_slot_until_after_the_decode() {
let mut planner = Av1Planner::new();
+89
View File
@@ -534,6 +534,95 @@ mod tests {
aus
}
/// **Our own host's low-delay HEVC**, vendored beside the goldens the GPU legs
/// decode it against (`tests/data/lowdelay-640x480-h265.nv12.sha256` carries the
/// `punktfunk-host spike` command and the ffmpeg cross-check). 120 pictures of
/// 640x480 IPPP, a five-picture DPB against four marked references and
/// `sps_max_num_reorder_pics = 0`, so 115 of its 120 access units retire a picture.
const LOWDELAY_640X480_H265: &[u8] = include_bytes!("../tests/data/lowdelay-640x480.h265");
/// This rung is immune to the release-ordering defect for a STRONGER reason than
/// the DXVA one, and this pins the difference instead of asserting it in prose.
///
/// Both HEVC conversions release `removed` inline and let [`SlotMap::assign`] hand
/// the freed slot to the decode target. DXVA survives that because `H265Planner`
/// snapshots `dpb_refs` AFTER `decode_rps`, so the retired picture is not in the
/// marked DPB `RefPicList` is built from — a property of the PLANNER, one call
/// away from being untrue, which is why `pf_dxvadec::pic_h265` drives the
/// counterfactual through its conversion.
///
/// This conversion never reads `dpb_refs` at all. `pReferenceSlots` is spec-defined
/// as the slots the decode operation USES, so it binds `plan.rps` — the three
/// current sets, which `decode_rps` derives and which therefore cannot name a
/// picture that same RPS just dropped. The test proves it the only way that means
/// anything: it hands the conversion a `dpb_refs` deliberately widened to the
/// PRE-RPS marked set (the mutation that makes the DXVA rung alias on 115 of these
/// 120 access units) and asserts nothing changes here.
///
/// If this ever fails, someone has made this conversion bind the marked DPB — a
/// legitimate thing to want, since a *Foll* long-term anchor invisible to the
/// hardware is the RFI failure shape — and it now needs the `release_after_decode`
/// deferral the H.264 and AV1 conversions carry.
#[test]
fn a_pre_rps_marked_dpb_changes_nothing_here_because_the_current_sets_are_what_bind() {
let aus = split_into_aus(LOWDELAY_640X480_H265);
let mut planner = H265Planner::new();
let mut slots: Option<SlotMap> = None;
let mut prev: Option<AuPlan> = None;
let mut converted = 0usize;
let mut widened = 0usize;
for au in aus {
let plan = planner.plan_au(au).expect("the low-delay stream plans");
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
// The marked DPB as this AU's `decode_rps` found it: the previous AU's
// snapshot plus the picture it stored. Exactly what `dpb_snapshot()` would
// return from above `decode_rps` instead of below it.
let mut as_if = plan.clone();
as_if.dpb_refs = match &prev {
None => Vec::new(),
Some(prev) => {
let mut marked = prev.dpb_refs.clone();
if let Some(id) = prev.dpb.stored {
assert!(
prev.picture.is_reference,
"this stream carries no sub-layer non-reference pictures"
);
marked.push(RefPic {
id,
pic_order_cnt: prev.picture.pic_order_cnt,
is_long_term: false,
});
}
marked
}
};
if as_if.dpb_refs.len() > plan.dpb_refs.len() {
widened += 1;
}
let vk = plan_to_vk_h265(&as_if, map).expect("conversion");
converted += 1;
for r in &vk.refs {
assert_ne!(
r.slot, vk.setup_slot,
"a reference aliases the setup slot even though this conversion \
binds only the current RPS sets it has started reading \
`dpb_refs`, and now needs a deferred release"
);
}
prev = Some(plan);
}
assert_eq!(converted, 120);
assert_eq!(
widened, 115,
"the mutation must actually widen the marked set on the access units that \
retire a picture, else this test asserts nothing about anything"
);
}
#[test]
fn the_full_25fps_vector_converts_with_stable_slots_and_start_code_offsets() {
let aus = split_into_aus(TEST_25FPS);
+16 -3
View File
@@ -44,7 +44,19 @@ impl std::fmt::Display for SlotError {
impl std::error::Error for SlotError {}
/// The slot ledger. One per decode session; feed it every [`DpbUpdate`] in decode
/// order (via [`Self::apply`] or `plan_to_vk`, which applies internally).
/// order.
///
/// [`Self::apply`] does that whole-update. `plan_to_vk` and `plan_to_vk_av1` do it in
/// two halves instead: they ASSIGN the stored picture and hand the removals back as a
/// `release_after_decode` list for the caller to apply once the decode op is issued.
/// The split is not a convenience — releasing a removal before the assignment lets
/// [`Self::assign`] return the slot this AU's own submission still names, which is a
/// picture decoding into one it predicts from. A caller that drops the list leaks a
/// slot per AU.
///
/// `plan_to_vk_h265` still applies its removals internally, and that is safe rather
/// than lucky: `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`, so a picture
/// this AU's RPS dropped is never in the set its reference lists are built from.
///
/// Invariants (unit-tested):
/// - a [`PicId`] keeps its slot from [`Self::assign`] until [`Self::release`];
@@ -134,8 +146,9 @@ impl SlotMap {
/// as the planner's DPB holds the picture — as a reference OR as a decoded
/// picture awaiting output — and that residency ends only when a
/// [`DpbUpdate::removed`] entry reports it. This method is that report's
/// primitive: `plan_to_vk` and [`Self::apply`] call it with the planner's
/// `removed` ids and nothing else may release a slot.
/// primitive: [`Self::apply`] calls it with the planner's `removed` ids, and so
/// do the conversions' callers via `release_after_decode` — one AU's removals,
/// deferred until its decode op is issued. Nothing else may release a slot.
///
/// Releasing is CPU-side bookkeeping (the slot becomes assignable to a later
/// picture); keeping the released slot's IMAGE out of reuse until in-flight
@@ -0,0 +1,126 @@
# SHA-256 per DELIVERED frame of lowdelay-3840x2160.ivf.av1, DISPLAY order — 60 frames.
#
# Each frame is the 3840x2160 render region as tightly packed NV12:
# Y plane 3840*2160 bytes, then interleaved UV 3840*1080 bytes = 12441600 bytes/frame.
# (`render_width`/`render_height` equal the frame size, so there is no crop.)
#
# THE STREAM IS OURS, not a conformance vector, and that is the point of it.
# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04,
# punktfunk-host 0.25.0-0.00011708), 2026-08-07:
#
# punktfunk-host spike --source synthetic --codec av1 --width 3840 --height 2160 \
# --fps 60 --seconds 1 --bitrate 1 --no-loopback --out lowdelay-3840x2160.av1
# ffmpeg -f obu -i lowdelay-3840x2160.av1 -c copy -f ivf lowdelay-3840x2160.ivf.av1
#
# The spike writes the low-overhead OBU stream; the IVF wrapper is added so this file
# is framed exactly like the vendored vector and `common::split_av1_aus` — the
# vendored parser's own `IvfIterator` — splits it with no second implementation that
# could disagree. `-c copy` re-frames, it does not re-encode.
#
# ⭐ WHY 4K, when every other fixture here is chosen to be small. It is the ONLY
# resolution at which our encoder emits more than one tile. Measured on the same box,
# same command, 2026-08-07: 1280x720, 1920x1080 and 2560x1440 all give
# `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` —
# `width_in_sbs_minus_1 = [59]`, `height_in_sbs_minus_1 = [16, 16]` — and BOTH tiles
# ride in ONE Tile Group OBU (`tg_start = 0, tg_end = 1`). That is the exact shape
# behind the defect where the host shipped only the first tile of every 4K frame, and
# a single-tile fixture cannot express it at all.
#
# 60 frames rather than 120 to pay for it: one second at 60 fps is 261 KB, which is
# SMALLER than the 282 KB H.264 and 270 KB H.265 low-delay fixtures, and still leaves
# 55 of the 60 access units exercising the reference-slot pressure below.
#
# 60 = 60 = 60, and that is itself worth pinning. Unlike the vendored vector (250
# temporal units carrying 274 coded frames, 24 of them hidden), THIS stream is one
# coded frame per temporal unit, all shown: 60 units, 60 coded frames, 60 displayed,
# one KEY frame, zero hidden, zero `show_existing_frame`. The parity legs' frame
# accounting must not silently assume either shape, so the CPU guard asserts all of
# these numbers rather than deriving one from another.
#
# Main 4:2:0 8-bit (`seq_profile = 0`, `high_bitdepth = 0`, `mono_chrome = 0`) and NO
# FILM GRAIN, so the Vulkan decode profile is the grain-DISABLED one, exactly as for
# the vendored vector — see that file's header for why grain is a profile property
# rather than a per-frame toggle.
#
# ⚠ WHAT THIS FIXTURE DOES NOT COVER. It is a FILE, and a file is not the wire path.
# The headline "250/250 delivered frames bit-identical to libavcodec" was true for AV1
# the entire time the host was shipping only the first tile of every 4K frame: that
# verification ran against a vendored file, the packetisation and reassembly it never
# touched were where the frames were being truncated, and the suite stayed green. This
# fixture closes a different gap — it is the first pixel evidence for AV1 from our own
# encoder, in a multi-tile shape — and it closes NOTHING about fragmentation,
# reassembly, loss or the session's AU boundaries. Those need an end-to-end test.
#
# Goldens from libavcodec's SOFTWARE decoder (AV1 decoding is exactly specified, so
# every conformant decoder is bit-identical):
#
# ffmpeg -i lowdelay-3840x2160.ivf.av1 -f rawvideo -pix_fmt nv12 \
# -fps_mode passthrough ref.yuv
# # then split ref.yuv into 12441600-byte frames and sha256 each
#
# CROSS-CHECKED between two independent builds on two architectures whose
# 746,496,000-byte raw outputs are BYTE-IDENTICAL (not merely equal per frame):
# sha256 90c5be20342cba4d80bd0ceb1568cc3e2037f427d34c658895c7742968c93600 from both
# ffmpeg n8.1.2 (Arch/CachyOS, gcc 16, x86_64, libdav1d)
# ffmpeg 8.1.1 (Homebrew, clang, macOS arm64, libdav1d)
# 60 of 60 digests distinct.
5c587f77a16733533c178c115cf4fad5866b13ebc4bf7673a72be062b4aab9d9
b07527d4a8d7fb8b4ce53d97818e86493237e98c492e5718d42ea0974cd00da2
59156f4f4d3ef030fe884e7085a2f1b3887ba00e0c8e58c5e3f425a31f5aade6
693bbc7da0f499d2955e6c13ed6ee3c13603faed938bc357264c534bed9faa0f
14d1722b39ef61ede697a87f5d11eafcf7d9a812fb966fd3142c483036a08b87
94e6cd41d300298677b014650e5f19b420c38e82741452da573d189d947f1a41
f832d8126cdf3195be4a25aa5e80eefdd2c1089dce5bb3d3531f9bdb58e9c3b0
be9248bec91e4bd5ab06e3a32d1ac02b759ae94735ecc88b14b845c5e4f24120
581fdce69ff5188c949612fd00bd801c717ab9fc1f2de635d734879aae0a7bdd
9256582fc2d242e57a2fbd414a481d4cfca494fa56254bdb15004b4d65c866c6
7955081dd59795399a1cb7a3eeade261ba01ef89c8c27f1e133c958be054ddec
5c8e963f60385add18001a3992fe5c5abb697391213f0f992144e5bba8f00745
13062b6a6e2c557f4b8956a2f05a9ec941b3bd15b6ff4c337662b3eeabc75c41
a80151a16345232be1db60fe2c1e7e61df38a1f676868fcc1b367fa42cdfaf9e
3bb8d3934ce8b772d922c3f9004a2ce4db0bb1b9dcc63c5ac2ac90900b5693ae
0e2b7d0148bcb1873256fba13058607b6088dc0478e9ed4773b657cd5d2d736f
a620a352fd7f71675ea408a53c65e193f986c4e03da8008df996858410804323
d89b4fdf771dbae3422dc193b8425af78a8a27520a09f2c8039fc6bb1501fd9a
8bbdfbcb580c3c2694b8666869abd4cc39083d3dcf68d9b9ddfcc0f87ba8579e
f779dcc8cc901b9fd878ab7f7e0b74ffe6123711fca9a9cde5c7f79f8edfc2d7
172994574f60daea3dc17097a9ef001528cdd469388ee1c168021e622fb9616b
4ff8294a6e9866cab539ec30a434484370b74b529d4f1b8a83186b6cf3862abb
4a23e983efa8d890f89f654813032996af9ec74967e0bfe3bde9c1157e7c31af
3f35ff00a47818184adcc25d86546503d62dd15ee1992971fe8c8d378fb1112d
dd39f5cb55e8ec815dc8608f59612823cbfb96bdf4a470c9255ce745fbe43a7d
62208c265976740965ae43a75c63e18eb1f6dce9781c98db105c4b4e6bcaee05
a4227c20e5b717c8d6f3bc02290bf5c6f14ffb5655ee0b5d56ea906de39471a8
99e146b61d3591d32f297b87eb02957bbfe7a323bf6b09da0f25840018ff3168
7abfa53dc8d8902eee943cad6bd3d9893954c04f52ef999f64822ebab935f474
69a8db91bc8767047ee167362d235c494be63957efeef400e12d447c65136438
8786d36d405ab6b2493e98415784e33fe09b49fe9730ad899ec1db418ab71122
fe5bb80bdf4a100305b157fb1f60732bebb9c7b70ab22726004bb4fc9925705f
264595ab85a2f8e30d316e48b02d8863c341d8ddc9d34e42affbad9520958027
cc96871daa10607f13227d6dbabf56f9ffea7eb2acc4d71272cffae286da1970
911bf0ef3c152bf45f527de8b4deb5a60d80f5793bf7488dd57009b1cbdf51be
543851960eb05d8b2a0a3b7ee50ce6fcc975b4ac6404247464e535364e610804
e650439e22ebc94c24479a3d28472afb776389f4eb9aff62c8eaa7b96223e014
3ca9d4ba265c19540c73eb51c9d4003ca3b762cf4d89f6d771d6c9edb07df65f
f106640e15ae6b53056c9fb64e94b2a3ccce80a32f8146a5cb170363c1a5d019
b7004a617d142bb7238063c35e1173888aa028282fdbdad8ffaca3519fb0228d
98f03d7add2fbd046b6805514d0844abbf7c2ee43de2509d3344843ad2d7220d
15c7a6c87d1ded36db5b5aa9e8394b184d3a6c7ccee4e1041b2473db5a1d0d2c
8d98f9655751c2c2afc7d4a87dac58c0d185e7bf2d897f93b0e999cef7897920
91cc279dacbdae06ca5cde05c395e73cb675802e1a4f1ac3ae95a0344e2ef6ec
d042152a54fb3a0c129c2beba6c73609c4e9e4abb77d5c0bd731832bd70c6ab8
5a9a9ccfce845caefcbfca7ac553674f84ff9a7f2846c55541512290a89a9295
6ed1c17c84a6c454600b079ab709782cfd494adc5433613738158c5163e60211
116d24252b578e6e4519611b9542372175b3fbed7ff76276daae6e0c8d2cfa77
edaf4b5827b58b5712266d84914706b76b4aaefffa2e70a703f5261e9c774c00
85b63f4a86b1e5e19372dfa98040382c94114bdff710b301430ec1894c0d06ab
bf19eb95a3fdeed167d2a2a65fbbcbd426125451de649d018f7c1e2bdfeced50
e0dc2d2422909b997cd39d2d66842f2f9da9f9002f6b46b93ed2666a324e04ab
68ae1aad5ed221897100680c932f5c5ce71ba4957cc6b784a9ebc0775342d714
784acdc2a7cbb0cd135c01dac00d4d63385fce60f4d053d30e9b972d97bc6db6
5a08dac838c5f012e79e60cdf17ab25db8b58ad83df9b2d1b94a7c1f40e04943
0c53d382480cc0ef5402dcd71d80c23662a9c3078b3989371eca11a4000cc4dc
a50fafe9ad90c4cc77dc6ada3184e8a7fcd744fd57855ee05d40cc96e6cc10af
eb1b41ec865ab2b6090cba6603c687bd059aac1d183ed76846cad1b9a579c7e0
92d9d93b9cccb596ac7022aaca97e32b4ec7417dc8f0047a9575957464d6b5d5
210d44dfefe088b47caac2861171abdcef07c828ab673fa98a6e64cda8d42107
@@ -0,0 +1,165 @@
# SHA-256 per decoded frame of lowdelay-640x480.h265, DISPLAY order — 120 frames.
# Each frame is the full 640x480 picture as tightly packed NV12:
# Y plane 640*480 bytes, then interleaved UV 640*240 bytes = 460800 bytes/frame.
# (No conformance window: 640 and 480 are both multiples of MinCbSizeY, so the
# coded size IS the display size — the same reason the H.264 sibling picked 640x480.)
#
# THE STREAM IS OURS, not a conformance vector, and that is the point of it.
# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04,
# punktfunk-host 0.25.0-0.00011708), 2026-08-07:
#
# punktfunk-host spike --source synthetic --codec h265 --width 640 --height 480 \
# --fps 60 --seconds 2 --bitrate 1 --no-loopback --out lowdelay-640x480.h265
#
# Main profile, 4:2:0, 8-bit, level 3.1. LOW-DELAY IPPP: one IDR then 119 trailing P
# pictures, ONE slice segment each, no B pictures, `sps_max_num_reorder_pics = 0`, so
# a picture is output the moment it decodes. Its SPS says
# `sps_max_dec_pic_buffering_minus1 = 4` — a five-picture DPB — and 8.3.2 leaves FOUR
# pictures marked in steady state (116 of 120 access units), so four references plus
# the current picture fill the DPB exactly. Each P names exactly one of them
# (`numRefL0 = 1`); the other three are 8.3.2 *Foll* pictures the RFI window keeps.
#
# That is the H.264 fixture's shape in HEVC's vocabulary, and it is what puts an RPS
# drop and the eviction it causes in ONE access unit: 115 of these 120 access units
# remove exactly one picture. `test-25fps.h265` cannot reach it — it reorders.
#
# ⭐ HEVC is nonetheless EXEMPT from the aliasing that cost H.264 and AV1 a deferred
# slot release, and this stream exists to keep PROVING that rather than to be the
# stream that breaks it. `H265Planner` snapshots `dpb_refs` AFTER `decode_rps`, so
# the 115 dropped pictures are never in the marked set `RefPicList` is built from:
# measured `removed ∩ dpb_refs` = 0 of 120 access units. Move that snapshot above
# `decode_rps` and the SAME 115 access units alias — the counterfactual is measured,
# not argued (pf-dxvadec's `pic_h265` tests pin both numbers). HEVC's freedom is a
# property of the planner, not of the streams we happened to test.
#
# Goldens from libavcodec's SOFTWARE decoder, the same ground truth every other
# golden set here uses (H.265 decoding is exactly specified, so every conformant
# decoder is bit-identical):
#
# ffmpeg -i lowdelay-640x480.h265 -f rawvideo -pix_fmt nv12 \
# -fps_mode passthrough ref.yuv
# # then split ref.yuv into 460800-byte frames and sha256 each
#
# ffmpeg version n8.1.2 (Arch/CachyOS, gcc 16, x86_64) — cross-checked BIT-IDENTICAL
# against ffmpeg 8.1.1 (Homebrew, clang, macOS arm64), all 120 frames. 120 of 120
# digests distinct.
9f9c0286da4917dd02897f75d61142f0e6e950c2795e835f6afcc849d6778e5b
269e68e0917f62050cc6e134a9355d621dbb6dd1ab162f809b86c44df3d8fef0
3297b14670d010b01f7c5afba4a1309055965d4b1cbdcf644f3ae59540330e79
f6f7b529995b9d658a7a30a2b8df8381a494b18d4a92441c350591e2221656cd
c32d3e28e7322d08a5a4c2044b3d6f6a8a5b093818e378e9f68ecbf787c3e89e
51b534a44bd5292dbfe189108f0cd2ceaeafd35433762113d25ad17aee95ee9f
fe23e229a7154d5ddd965e7131a3db1928fbcc91525b96d06bf354ecb390cd36
a18313577c9f658af750ac5deb71275b7cb31847030064b7aa1eabfa1bca8b6d
bd372841ca00e7ed8cf88bd5b47f1d2aee0a87c9c40fc1d921ce5135c5ff0782
baa5e7a7a2c86ac428dfe3ed3bfdbe399ff8f339186c48c181840a738010e6a7
43e84f559307c12c0a4d8acfd259cc3a73aa6d9e2951dcf05c45e4185becf75b
c85779050cbd80826c554e62d5b9435f6e6cc31c0013d288045260fe576dfe8a
93f5292b0436b37290e644f0e45aa987e39c97666f182e54709eae42512e40b7
163fa586ec9813079ed0427bfa046f846359c540763b4437062c181a219d64b2
2851fa698eb3889235d16a7e9497153b57f6e1ba79133f038586c13623b1893f
3ca46e9b37fe4c0330893f5d91ed9592bfa65725d703be58d00bb57d2aedaa8d
30a70c37b70f224198c09b7606464fa9f2ed578cb33a467832b2864db01bafe6
7cd5448fc7d149f311fabef84893bb91b5127c22fdba429eabee1b10a266eb0b
79bda1e5dd8136eb2dc5cfc607dcb1bdd2a3b58a3c85133f52e54f21bb138e99
9aa3f7dc22d68dbf76cb1a71fd476f5f8b272b7dd6676383945c37611ce26f56
75b1151b63f8e071cadd3656edae21e6755cdef194730e4d9d364df67ff63b56
a6fe1a709aa69329fa96ab90aa4561a57b42cdca1d6dc3f41b00b8466bbd2bfc
a309129ad54517d8839ca4ebf12bdcc4c563049fe991d8a7c0fa57ba8c75c6ee
dff37bb18055c1f4739168c882ec694b027998b960e341d6815a47cd87942a14
ff334206b81c8664cf2a59df91d0cd3fd3bc8f45f6ec918312df75e61d6adf74
916f50363a424907d97dd1914808770ee06716f70c6040a44d0c1c9c66004b3c
a2bdd4550eb5a43742d667a1538102838dd81e7930c76b607df23dee4d0c2c03
e5f70c0a5df394b9dbe49bb6cefb0c0ce799496ee1cb02ba85366415376d9863
f154dac657645f5aff1e0f47aa8ffd66b7205808fdf345c9b153a70770eb0702
5d83483a3135908c233f9c8719115b823ca4c384cc3d48f9d43b2ce174e55c12
60ea060bd5f80d2ed291f7fd87a317adc8a8eab5f2ea0bcda130e06f0bc4b21b
3cbd406035c302f48874c509f0a2f32f2a3ee6072d87a97ad91728abe9778176
43c319ed065cb55d59a6d9fe98a70884563ce11109e54dfd99d5a4ce695ef355
a6416844bc2d08e80415350ea6a77b37e0a8c5ea0a13bb284ac6f4f3a54882a4
b49fc7a97971eef0c7ce3264a5245de9c323fdb8c1f8d5410f5ffda6bca6c187
23754bb46f0938a29cf3b58cb78530701b3ed7e8a2d6fc55a168b2b0e6c9bf19
9bd927da6114b539327c3c9010e01a19bea941fac296acc7cea4ff59ee2e21cb
774b5b0f9872c4d95f071ea16dc848d99c7bbafa22228d6530501d1e9c405e22
ee244d9663d700085033571e63649beb169f3464b5c47b435beb5e56b6513033
c552bc82788cb0b793361a3beed54b0f1ccbb03c1a34d29491a07acea615b980
31a92d0c3a8906c742034f36720481d50d2b7913f2f8225f78ef6ab8fb104a8a
3f0581af032d83dd7cc2cf0afff04d225a48a42dae3e3cfb6cc72b1218b49dc8
6f0bb48a98e7a971238bb4d90432ffc675c55bab2571387e0fdc9f2d8acfdc7e
4f51f9aaa83108587eb3871a22c7ebb34a7f66dbdd81689de03f10f16f8e3207
90a47f56c6aedd15de6dc3798bab53f394c4095c1fea933f2d8af83763d2431f
1e8c8505e4e9f7ada9a60b6d6f66d30961c3f5fe70777a71b4e110079a435164
6909300c3db9cd6db449bd6e3002b94052da02000105f89bf38ad4babab4e339
caf9794215bd2ceebbabcebbd7d3de3d86ac9fdced421ab30ef0cb1eea9f472b
232b867d97898839e331b9a9aa971e5593b4e360833ae1230272d426fd3796a5
3472664dc348f434ffd0b58215cbdd17c9d18fb8112c0b1bd3781fd23da77edc
cdfb83932eecd4c5acaa12bad0d7f04d2a87dcd2812cd0a10ff3ddab3ced0484
4862527f8256fa9c1817c7db32a9237ca9078554de336c7ec231cdf92fd8b481
95ef6abd712678dec27238e9f5d983c632ea606c68a17db8c13db5f984106753
2a37fa16ac36bdba0a934a4fd756bb0dc40685c4db63cb1c919f1737e2bcd497
798d6c594aa1924205e947e8b48a49822dae5310f1938e31d7fba6b538bb0689
f7e8fcbfe4074d0e8cffc88ac5be30cfcaae2bdb4d6f35b32c627ef1eb1e5b44
87eedb3cd8a47c70cf23c7025a53176dac83d9928d66bbc9750169b15df65fee
e3f24c0c33a4442c7f3ccc4fdc4d42116a92c1afa2a108d9867493c25971c6c6
6fba290349aea3ad2f0b189ddf608c07e3c7062e5659464453e9198f80fb1a06
fb1503234b54566ab4d4fb8f155ec779e6e83c14eaa2ebbc51502636b8142362
25a2d7396d612601b65306d166fa9353eaa63a820c88a4a2ba4891ebd6527c73
52e600c978c89b4e0580ea63a4131edd445cb1a83d5978db99e09bf115795c43
c576c1f537bd10065c93ce01bee7821b5d947d418c42f2941a65c106a8866ded
fd925cf61ffdd98fad5388c8bde2f3e4dfaaff0de0badd3e28998dcdaaa22790
ed06b90b70480467e2bff3aa44fe26356065393b09916ca55b705147e79a4532
bc311e2abeeceb28e409a5c117a303100a5b8bd72a9212da8370ed125d23267f
611f87e03ab96ef0e683968611f9ee9b6c0052457a513217f9a20cfadf98c0c8
781aa26b54f6e3a941c973c1685323ebb6a4c8a024d575a219138d2f7500f12c
e1bf924e000066db999ea9df03f444544ef9b5ced6903fd6e11679b1bcde4288
1dbbe9f9039f67d630e0474830a9b4908453dabc705a726990e5da33f8265ef0
6b196b22ee1b5f5a41129fd6ea4c31217010d656d07eba1b519372483e225d5a
f98c6557358d7d69ad084a39780e46c3f6d2c882a95b14e196453cf5683f2e1e
2d1f2d2e730d80b0a0e9186cf706fee66e5919b724cc048fc26905766baeb4fb
fc6e7638091a5a8fea2c2fb93de326466d0b8b2791a137b6c326cf873f780091
12ace0c4843c04cd1ab0b24d11374f92862300dfb04402c53dbb2a3846d3cd1e
ec3b094cf0a85e64794afb1a423fcf6ac2d4ac49bcd0eafaff93c87c5ea5da80
4eabff56ae2893cb8d18b994e8922d98517f4bfc26f03572917ee04e9c1f2c21
c836a55b5690dc31db03d5373b65660a7a56b3dd6ecb1335daecfdd798dbae49
3513c96a153957e34cb5b4568fed9cbc9f008b40c7f7c8888419e280b9552e72
98d993995f82e40736a96d78088f79ef5b1e23a2f2c8612a995581d2e4339c89
a7e18f4986c14870c0f5703287acf0015e84265c59cd81ec4da249a156eaf344
c67d1b2cdfd341d605fb1a0104fbf39dca7a220a20864f6e258c5261b363d2d6
d49987c3cc626dea121b049f26ff92619fabc0c9ac2f4d7222fd5a4f3cd05106
c1af9fdac737422cad1e70fae5660268f51c1fa2ad565617d9aef0c6567bc1f2
988b41b171cbefd8844891dbf1dda9586f4f7d855fdedf9084ce1a35211bf4c7
0c89259a251df6835d43f2d41b3ab680191ff8a2a1b52f38067f5ff6e64c46ef
463acd75505bf1c7f9cdbac61ffc447fe7dff36832dd6df2da8c6fb034a7ea5d
b87b3ae2d51b989010cbc44d020db3be1df563727cb1828ed20be75138b391ca
f224bab42976a46f976e3792ec15c7c143fd40c255577ced88c4dc423f9e521d
83248fdcccac574bf2550594894668e2294b60a51e02511bd7ed1a3d62a0a833
e21a46b8aa370956a53c6059663c95846d6de9c21b911e9b35328642cd2b6a93
1b25f894a5e039940be81195d73bc47b81dbe3de0a02172c47e8f3de53ff453e
b91a7d7f4545adec12ab1f1646a152aa7a411f52fe85ba6d0a05e26233bd9554
76fb5d9740f2f31d27c017d14f2641dd71debd5ce1322f993e4d4525f86913e2
f69e6a8e6d1f9022fa3b4d7e27cae866d827b0d70ed4a1475cec54703e3028be
89e799beb6d7196de0f1aaec171c0aa7c5ab5b5061e7ea6431a4b9ca2f9f305d
a48dd4d45122187814b66952700536336d2d86e3cf4c90499416ee4178c82df6
716723e849b5a3fc13460c45b7b4f236bdb077a40eae3f1bd6f63970eccfd6d1
6d6f03ccd7e223c150bf4dab4667ef4cca83ef24db79dda73c1d3d132cd8abec
f6b9a0795d6202a1f868768fead3a3072f5ac84a31bb741b1c2e6435bddea335
93db9ff6332ea465c026c6d994e214d7fc035dac3b28cc5df5100633c4b3d355
f2f7b1a021abe83b5635da215b72d341c58532fe0aaa6c72570e1ab4d8bd702c
e1cc57cae82018761af96d75485c9564c742b9c180709f41cdac28a08b4734c1
bd8b75c1bddaca942b0645efd1774ca6cdf2b1254f65aa3bb52160a635c5820e
454d9a6027f89a23680378b0ff469b0ddaa277f1daa1fc953ffbf3a86c9b56e2
52dd5395d96d8097515707287fe2f53c140e46addca3d4407b1b9f9685569ce3
df850ef27b8e2db55f1f03e4fe686a22897b5266e37ba6d56cf97e9adc0a6e25
d6ee7735b22fa97e1ee85fcbf7bea1609ed703e98a4076e1474e8d152794ec6a
4bfcb046b8175eed1732bf52d5abd0540d73333e1b3efbf86b8dfb1a76aeca57
632c87450e3b5b763d87890b5ed5ce38775af5359b858f716cfbff651838c01e
2784dd87812c4df4793ff50409626d47bebd4149caa4b5098efb5ec63c59b5a1
480b2f63b02ba2436a3d026701adafc27ed8fbc100ebe232257ec62c54166767
81d1b8196e7bacd89ef90494b48772b1eef52afbee49aaf907eb264eeb11d660
a045bf5565f399c293363f2d08315b60ae6bd6a524ca537668e3afb3b9893d9a
767ffcd6a3b2574f641c1f298c4606cce88b08f76ff5abbdbfd8c8cc8b88cb86
54cb01c9f411e1994b5a2b2cea22032e232a1d76874b5b6a991474058c3b3dfa
feba0cc0e710d509963cf7f7c6bd62e6904d6c5446fb784bd3508520e1a3371a
ae0c6d458623d31eeb59e9170f983dec17ca5885614dbc82e67ae45b9ecb11c9
e6d0f8d4ee60e94199ee39ed6f7b2f6c4d4835993c981086f5b022ba4bd1da6a
d70133bc51be4b64510a0411c07dc2479b04f18b1b561cce21b91f6a3a9508a2
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,149 @@
# SHA-256 per decoded frame of lowdelay-640x480.h264, DISPLAY order — 120 frames.
# Each frame is the full 640x480 picture as tightly packed NV12:
# Y plane 640*480 bytes, then interleaved UV 640*240 bytes = 460800 bytes/frame.
#
# THE STREAM IS OURS, not a conformance vector, and that is the point of it.
# `punktfunk-host spike` on .21 (NVENC, RTX 5070 Ti, driver 610.57.04), 2026-08-07:
#
# punktfunk-host spike --source synthetic --codec h264 --width 640 --height 480 \
# --fps 60 --seconds 2 --bitrate 1 --no-loopback --out lowdelay-640x480.h264
#
# It is LOW-DELAY IPPP: one IDR, no B pictures, `max_num_reorder_frames = 0`, so a
# picture is output the moment it decodes. Its SPS says `max_num_ref_frames = 3` AND
# `max_dec_frame_buffering = 3` — the DPB is exactly as deep as the reference count,
# which is what makes 8.2.5's sliding window unmark a picture in the same access unit
# that the C.4.5.3 bump evicts it. `test-25fps.h264` cannot reach that shape (level
# 1.3, no VUI bitstream_restriction, so a level-derived DPB of 7 against 2 reference
# frames) and REORDERS besides, which is why it measured zero aliasing while every
# stream this program actually ships aliased on 99% of its frames.
#
# Goldens from libavcodec's SOFTWARE decoder, the same ground truth the vendored
# vectors' goldens use (H.264 decoding is exactly specified, so every conformant
# decoder is bit-identical):
#
# ffmpeg -i lowdelay-640x480.h264 -f rawvideo -pix_fmt nv12 \
# -fps_mode passthrough ref.yuv
# # then split ref.yuv into 460800-byte frames and sha256 each
#
# ffmpeg version n8.1.2 (Arch/CachyOS, gcc 16, x86_64) — cross-checked BIT-IDENTICAL
# against ffmpeg 8.1.1 on macOS arm64, all 120 frames. 120 of 120 digests distinct.
be912b67b89d720b4e9403bde4c52f0e4414571e3323e4b5312ff5dc26309dd3
87115b7a352e95baec35ad8bbbe45af5ac6c691264291c9c904e4d01c2e6f911
03ad701f6c1c4421ad6f8396ed24b893c275d245cf93d24a4f7cf59f9c74675a
faee80c2c494b009b6bab7d9e6d6cb5ccf0921cfa1bdba0d83479b19d2a6f012
a01b6234ff0bf5c222a0de7b574075f7dec5fc4ea3ccb6657941c791251c6242
d0befc0eb1e018b671dea55d74009ef1d09cdcba392ecd8509ec000aae91fe1d
f15c273a6077f73296edf4e245069299c034e4a3f4dea10006d9bbf29f8f4220
5e08336bba0a20ffd3efb8659424af9b76da0252c0cd5f1ccfcdbe92612bafc9
a8bff0f9921ae301c17e83885760e3e085845732f8d48c4a0fd70f591e3043af
dd90da5b869d1026fba4e41cfba059b8e75fca383bd1ab76c493a82271e1141b
e9237c44040f78e453129daf71afe5e24a640a67524b1c8460c9efaac8eb4795
fed123e00f51dab09a4b9964bd289c9db95b6a83564d35a5a5e3d5ac2ab8f371
d7f2db0a52f5a0d835ca9d7d74ec30da29b36bba94b30c10058715c505e26cd9
25ac9facc9d25c85d0d30864985a0fcad635bd7e65e4773a03e620cbd510ea97
6d6558eb98c4fc4c8a3dfe9e9eefd744b3cf66f66c6f09c40afbaad272f3f16d
678cadfadfd980eac5aa8ca83eeff2112249262cc4747fac49ecba49a8fbbd42
031d73796c6f2b9589bc1443937f77156d52aeb602fc7fbc7ec34f02e1ba33ba
59f26c57bade55876dc4ef17070a703cb5585f059f8db7a477a752e731830615
783d10022eb2f8749f7130954b899c74ad950fb98305a8b0f83ef5a78f38207b
e8d8ce42e46c4121be8a270d2bb30c98a3d11bee7808dcacbdb19e9318e66989
0b2fb3507e886953ab801e03a63715e6a6a26088de9fc809a56f454e1e2da069
8bc350ca94eb356c1bd2f221d482eb6e6cdc6e132366236c8ec4b1a41289a01f
dd0f472b9c78ce5b2b4fa2a2354c46090f208fefb12af0440e9a18a2d61b3167
08bceb5c011632733cc14edf01c301fa4ffe2ba5ee30424f518a23c908db7168
142b77142fa169ebd231308b0f3162eedeac66bfecb60eb9493abe16bcccaffa
c1dec8e1876afb17d0881c6df6e32e5ee1d8d931f3bbccb079f3ba44380721a0
e26bc6604826dd69d778ec77d0d3823a797faf32f838f3e13aceca3af15ddecb
bc7b93e2a39d2102539587d3aa96c4aaa58a13050a881bd4f9ced1411bca3a48
f41bfc62cce822129d65952cbcee15345e5a5fbc529acffdb223851a6f24fdce
3d3b23a6f161580f2f82382c01901ebe0345194d33e07981c50d4cfe0f39bf02
6e86c11618e37e770667245a679d359fe4a2f42c2a31ccbdc8eac2ee1a299fcd
9083b8c5455f513ed558954892e43b2b18c90928aa7a098ff49f4eff153ede34
9037d398f4e7cf295ad3bb43069c6a2fa96769c9b20617c9bebd1f34ccb1be3d
a333ed7ba7c7403b9b3a548e1a5f1669218ad85e47f90d69308eb037b4729330
aabbb4aa194537e260d5a11603fd1a9ef704d784b4d0ea592f7bea6d2a23af17
a46f730e965d4e39a3120ea43cb02ac94342073b1cf1e473e8d43c49b0cffae4
259d098d3f53248e889beff1517e4ca88aed4e3803b4ea4923d348c8109f5ac9
3fc9bc21d3420726606a5534c6516b1ea0cc2140392e0bec2855a45f6f090b82
2f3cebf32859649c29b1665c766fec4dbdbff2003e64be9e4720971d9b6ae2f1
f756e4459576a5aeb4c260c9a6c8e115e43ec97a64bfe74fe128f03c3c61b9a1
d6e101889565951515168958141a23950eb25637dd5202d55d24885eae4e1b42
1402b95affc25769e00d8ef30eeb0a831bacc41649246e48a46bac1c362ef3a2
7c838e5d2f191908e2d488185d209fe51c29a50e822ceb1a1f22b6eb6f28fcbf
ba39e5f0936af9706d9718390d03713a235b57102904e2a3972e67ff11b30800
cfe7b79981886bf74c50957f30b1a98bc4a064e2b41f8fb3996b0a3e15d132ba
47a02c20be1d34caa0a0d406a12d4970dbf642085442663070a8ae942323eb1e
495c75b4d163828750d0e828243bcbff08354418dfd2d43c5d1fd93c3bf691a7
5ed8d82a1aad0f27066028b4b1058d29f0b9ed5f424f378315ef18c883fe9fc2
85a9bcb2df1feb0053fc707028df68e9fcf3aed326ec2438ad111dc264aa69bd
b8247ee3d57539fe80bee41669a8a4c6955fd1e374b33cdfe6a6ad9fbb7d9fcb
bcc0a47694f3074d1c7b812f9d00c3eb57f98ee981d8acd7cb504bbaa04275f2
c5ca305e54f839a7e6f46d3280c17d81f5b3507310bec9a7fa2dae6a151fafd9
f5807f97f81927b2226395fc27a5d91eef56732916d9e78f65eb485fc4b846ee
3b9d1302b30c11e91eab92eedfcc9dad71a0382488808acc851589740f01498f
619f4862e224fe000acb4edb645fbdaf9e2784e4917e4e3c994052df123c701c
dcabed23f10e192e6103c032f6049adc2e30379f5f71e62489008cfc905f4953
85dac7f9dfe772670adb9087edc3825c201bd1dcfef412243e26317f5115c6d1
3518fc0bd4ee33d08976a51f23215960a16bee54e4abd09612c2012c6c716431
9e0761070df35ed19fc09b8795d66db6021142b99f69f549561390b61ac0a134
52f92a9c0c2cd9803077ca9a5ee88fe455cd8dd17c8fdba99638200c0a63014f
afe7313b2073231a6c2db4ee749b49f5f0af3105631a77f2b8b27fac0d430430
c09fde16b28b07291c8e35392a755e05875a4d2f83fbfc91c03ab4cfe7d2e39f
0bf84907d5bf24b62c17fb6f5747c410caecdeb39b5fb2449e1b2cd61b9ddddc
dcf0d031d689f362e04e29bfc2ebdd079fce9fab6624285b2da6e2acff61d1e0
25bee66822c1dfbac341f6a8b4e91f6463cd44ef24403823040810d4cb9d35c9
ec06e17bc3d2377b92c9c738964797a4fa6dc13645a1b0b563957a373f62e997
81e49365823b69f1dadd84a0fcb07e0e21e573664f5dc3f4e3cb3ef5b70b1bb3
cf7a344c4bbd78d334131dc0e9c4cadb7e4ba1540a16fae268652cc81cda3ef0
03b9528d2ab09525e80257938bbed9fc4c8f50308e1aefc368c1702557882134
05a20b533ab2a7345f2f7ca4c51974b5594779fc173dd405e5361f70bc28aff4
f77cceadf8138be43e272695706573b3476215d788365b2e0f9deb881cade818
35d39e923d98550ed36dec9836896c784bd7c8eb38bf78c4f1490579cab16d0f
95e721bd96198c40d37ef9c9c1ffb4473800367c81dab7170cf70e92ff8e0d70
448d2db0f09244f85d7f851218d16e80b06bcba0ec2fc7e53e4bd1ab6a55b565
9e1d141ad0231a0c67767758cbce6dbc9fb40aa4b68af9b533cff1adf5b205e9
2af66d2f3158c7da06854f881a9212bf6d4ed09dcc4d32c9e12ef3d8a1f9d28e
20760574c8c569d998e606d72eae71f8f705be80c383ef91deb71f4b0a45024e
9e0df9a3771307e7ce09b9efe4a4ea4a648d744f32816d614909b82c0c150e3b
47e3350d8dbb2012a9f92870c100e8e97cb16f519746c4f7e6afc3e0731cb05c
54ff346f3517b007cea9cebcdfc11b77e488b6ee86b1593a6b275f018ed7024e
8f7fe4882777cc53ca1c3af01b4321a584119eb431114c441a9f275c16e0adf6
ea08117dc667e86f3d99bec9fe7a76f5d6f675cb9d95bfa9d5e0e5ccaa498655
73dfddf6c6722d6a6d7db0220bbc823aa121fc75ed6be74b05549bfa527748af
e9fadef92b639b39dea8303926a63ac6aceb8ca83daf49c7a7018d0fb9e195a8
26eaeb1b0e2b5e9f2e88422c74bfaf162b2e3345cc6283096f96bbb23c88a59b
73dad6fffd397e25e122c01760b9b18f7cfb4d8bc53bd5f49168f6a91b9ac25f
3b28a8cbd94de49716240e43c3354c508222c888c7e86b645302924b630b7b82
c860ad68336e11d9f41cea92bc62f8016e1edb36240fce059743800bd31140fb
2843fef676d2600c81228dc2c4578f9de17004f22d0bb17ba4afc78f9adb22f2
9b01be7e851cbe311b14a5f904d94451cc467fe903aac8d4ed5b127967f02e47
1e583c1c8018d869e73d88dadb65bd6b69b294e7b32b12158a77e43005d59717
31f6da73506a565ec7bfb116086a4499992e357c96b2449c53e0b21624c579b9
d2dce5507ffef26cefcfb4efb3c4abbba4f4d7b02288a75b7243e12889ca08f6
b89bddfc2b94a40086a8169c4790352668e0365640cdd9cfc8f8256a8cd67a76
5d1f8f7562f1c0e7bcef9ba025b79c68bb0651b5d2039802f3489ca194e5b1f0
b7928b410e299e00bd69691f656b64de1598ac680effa3a6cc1456fc625544d0
02334e28175693b207b50e7ced14110afa8a8335fd22d00b2c4dc4f6d8a3480b
a290bf65e6d5fc843ccdb4e33f4b53ffa7d17363c324ce0fe91fc5c59bbf7fc3
191b54c810f0d694f457e4575d8c306f113fd6443dbbb0a403abb36e04381355
a1be53163c757fefe4bacf3b4c295cacf6d54e467a3a41bd1718bb5552efb0d9
cd151dbb675f5f25e34d08db74c9d6f203e9c7fc0fbe662bc8054c2d9c11a4d6
3c3f77e544b8d21eed0f4f39c68ce5804e3affaaa7188f53c193b040a44afc68
dba6ef4a4eb5cc89ee9dfe2161a0f727da6fc65aa646837a20601af9b57dfc70
639c0d1145a5cad6f18a21205d83b2ba1c48fd027efce0fed5477e5d89b3e4b8
353223c07d88e11081a886257460c8ff1b7ad8c957a567e43994cc9f2f08dc2b
0f0871d1f2c12f7b76a486da9d194b53756fddce56321aba2c2c16c8740bb377
343ffd487e7c1da166d6fe1acebaa1d2ef89fe0fc16d6ad108a3813a0f8c3206
625a4e45280b2028a0ca85e806ef4d716664a377fd6e4e0bfaf8cfe2fce2e6b3
4c4617317e3cfc2369faccc1b6bc5826a7dd92f8b06552fd6fcd2aebedcfb9dd
738c656ffd4e8e0a902c4da9cd7cb19316f0d4aa9f0aaa68c1353f7965abaa6b
ede77993572a017e4dee8a8abc14c6446eed4369385265e18a810afd32e8b3d8
3029c3cec839a7edfefd05a692cb25b50f029a10710aeef7791307fed8ca4203
325b6248633ee801e4c0d695f54fd0dbd6dddb61848daf92f74d9895d15923fa
3688c307fe659d4a178ac08afd7cdaa787abe55498aa537e8bd670a63088b3d0
c4372ea5bfb4003d1b2208a7eaa48259ed48ed41ba5cc930639b5ecd89d45fd8
4f335634ca6bbfe265197ae86b6ac3fb66dc2aefe8451f135fc8dc211dfe4baa
f1126e7188550c0e5d1da828d942ec48f1e0fc5963303ee3f28fa1e3b3c64ab7
27b8cb0963d22c67584ac2acfe0afa74328958617d6ca0287b772101cb7091ba
52c6865d58ec4b891ff3e6489475463f9807f2b4f25e60af439e52a04c0f2601
193dd2b43319b131d821dc554da172b1d36858838c6a88f1f8b747026a7e814e
+663 -8
View File
@@ -46,6 +46,28 @@
//! prefix for a driver to mis-skip and no second framing to test (see
//! `common::split_av1_aus`). Its absence is deliberate.
//!
//! # The three legs that decode OUR OWN streams
//!
//! [`LOWDELAY_H264`], [`LOWDELAY_H265`] and [`LOWDELAY_AV1`] are not conformance
//! vectors — they are `punktfunk-host spike` output, vendored because a conformance
//! vector proves conformance to itself and the encoder we ship behind is a different
//! stream. Each is here for its own reason:
//!
//! * **H.264** caught a defect the vector is structurally blind to — 117 of its 120
//! access units named one surface as both the decode target and a reference.
//! * **H.265** is EXEMPT from that defect for a structural reason, and an exemption
//! with no stream behind it is how the H.264 defect survived two milestones.
//! * **AV1** is neither: the vendored AV1 vector already aliases on 268 of its 274
//! frames, so that class was covered. It is here because the vector is ONE TILE on
//! every frame while our encoder splits 4K into two tile rows, so every tile array
//! the conversions fill had only ever been exercised at index 0.
//!
//! All three are backed by a non-ignored CPU guard asserting the stream still has the
//! property it was vendored for. ⚠ And all three are FILES. A file fixture says
//! nothing about packetisation, reassembly or loss — which for AV1 is not a
//! hypothetical caveat but a recorded failure: this suite reported 250/250 throughout
//! the period the host was shipping only the first tile of every 4K frame.
//!
//! # Why the AV1 leg exists at all
//!
//! Because until it did, the AV1 rung had no pixel evidence whatsoever. An
@@ -116,6 +138,108 @@ const AV1_FRAME0: &[u8] = include_bytes!("data/test-25fps-av1.frame0.nv12");
const TEST_MAIN10_H265: &[u8] = include_bytes!("data/test-main10.h265");
const GOLDENS_MAIN10: &str = include_str!("data/test-main10.p010.sha256");
/// **Our own host's H.264**, and the only stream here that is not a conformance
/// vector — vendored 2026-08-07 because the conformance vector is BLIND to the one
/// defect this rung had.
///
/// `test-25fps.h264` reorders and carries a 7-frame DPB against 2 reference frames,
/// so a picture the sliding window unmarks is never evicted in the same access unit.
/// A punktfunk host emits low-delay IPPP with `max_num_reorder_frames = 0` and — this
/// is the part that matters — NVENC writes `max_num_ref_frames = 3` alongside
/// `max_dec_frame_buffering = 3`, a DPB exactly as deep as its reference count. 8.2.5's
/// window then unmarks the oldest reference in the very access unit whose C.4.5.3 bump
/// evicts it, and the conversion used to release that picture's slot before assigning
/// the setup one — so `pSetupReferenceSlot` and a reference named the same slot on
/// **117 of these 120 access units**.
///
/// The vector passed 250/250 throughout. This is the stream that could not.
const LOWDELAY_H264: &[u8] = include_bytes!("data/lowdelay-640x480.h264");
const GOLDENS_LOWDELAY: &str = include_str!("data/lowdelay-640x480.nv12.sha256");
/// The low-delay stream is 120 display frames at 640x480 (no conformance window —
/// both dimensions are macroblock-aligned, so the coded and display sizes agree).
const LOWDELAY_FRAME_COUNT: usize = 120;
const DISPLAY_LOWDELAY: (u32, u32) = (640, 480);
/// **Our own host's HEVC**, the twin of [`LOWDELAY_H264`] — and the one vendored to
/// keep an exemption honest rather than to catch a defect.
///
/// H.264 and AV1 both had to defer their slot releases past the decode op because
/// their planners snapshot the marked DPB BEFORE the marking that retires a picture.
/// `H265Planner` snapshots AFTER `decode_rps`, so an RPS-dropped picture is never in
/// the set `RefPicList`/`pReferenceSlots` is built from, and the HEVC conversions
/// still release inline. That argument is correct — and it was, until this stream,
/// backed by `test-25fps.h265` (which REORDERS, so it cannot reach the shape at all)
/// plus one throwaway measurement.
///
/// This stream reaches the shape. `sps_max_dec_pic_buffering_minus1 = 4` against four
/// pictures marked in steady state, `sps_max_num_reorder_pics = 0`: 115 of its 120
/// access units retire exactly one picture, and **all 115 of them would alias** if
/// the snapshot moved above `decode_rps`. Measured `removed ∩ dpb_refs` is 0 of 120,
/// so the exemption is a measurement on our own encoder's output rather than a
/// re-derivable argument.
///
/// ⚠ On THIS rung that counterfactual is about the planner, not about
/// [`pf_vkdecode::plan_to_vk_h265`]: Vulkan's `pReferenceSlots` is spec-defined as the
/// slots the decode operation uses, so the conversion binds `plan.rps` — the three
/// current sets, which `decode_rps` itself derives — and never reads `dpb_refs` at
/// all. The DXVA rung is the one that binds the whole marked DPB (`RefPicList` is
/// spec-defined that way, and an RFI long-term anchor must survive in it), so it is
/// the rung a moved snapshot would actually alias on;
/// `pf_dxvadec::pic_h265`'s tests drive that counterfactual through the conversion.
/// What the leg below adds on this rung is the thing no HEVC leg here had: PIXELS
/// from our own encoder, under a DPB that evicts and reuses a slot on 115 of 120
/// access units instead of a vector whose reordering keeps eviction slack.
///
/// Provenance, the `punktfunk-host spike` command and the two-build ffmpeg
/// cross-check are in the golden file's header, as for the H.264 sibling.
const LOWDELAY_H265: &[u8] = include_bytes!("data/lowdelay-640x480.h265");
const GOLDENS_LOWDELAY_H265: &str = include_str!("data/lowdelay-640x480-h265.nv12.sha256");
/// **Our own host's AV1**, and the only stream here with more than ONE TILE.
///
/// The vendored AV1 vector already exercises the reference-slot aliasing shape (268 of
/// its 274 frames), so unlike the H.264 and H.265 siblings this is not vendored to
/// close that. It closes a different gap: no host-generated AV1 stream was tested at
/// pixel level anywhere, and our encoder's AV1 is structurally unlike the vector —
/// `RFI_DPB = 5` references, reference-frame invalidation, and at 4K a split encode
/// that puts **two tile rows in one frame**.
///
/// 4K is not a size choice, it is the only shape that has the property. Measured on
/// .21, same command at four resolutions: 1280x720, 1920x1080 and 2560x1440 all give
/// `tile_cols = tile_rows = 1`; 3840x2160 gives `tile_cols = 1, tile_rows = 2` with
/// both tiles in ONE Tile Group OBU. It is paid for with 60 frames instead of 120,
/// which lands at 261 KB — under both other low-delay fixtures.
///
/// ⚠ It is a FILE, and a file is not the wire path. "250/250 bit-identical to
/// libavcodec" was true for AV1 throughout the period the host was shipping only the
/// first tile of every 4K frame: that number came from a vendored file while the
/// truncation lived in packetisation. This fixture gives the multi-tile shape pixel
/// coverage on the DECODE rungs and says nothing whatever about fragmentation,
/// reassembly or loss. The golden file's header says the same, at length.
const LOWDELAY_AV1: &[u8] = include_bytes!("data/lowdelay-3840x2160.ivf.av1");
const GOLDENS_LOWDELAY_AV1: &str = include_str!("data/lowdelay-3840x2160-av1.nv12.sha256");
/// The low-delay AV1 stream's temporal units, DISPLAYED frames and render region.
///
/// Units and frames are two constants holding 60 rather than one, and that is
/// deliberate: for the vendored vector they are 250 and 250 while the CODED count is
/// 274, and a leg that derived one from the other would be asserting AV1's frame
/// accounting instead of measuring it.
const LOWDELAY_AV1_UNIT_COUNT: usize = 60;
const LOWDELAY_AV1_FRAME_COUNT: usize = 60;
const DISPLAY_LOWDELAY_AV1: (u32, u32) = (3840, 2160);
/// The HEVC low-delay stream's own frame count and display region.
///
/// Deliberately NOT shared with [`LOWDELAY_FRAME_COUNT`]/[`DISPLAY_LOWDELAY`] even
/// though the two fixtures agree today: they are separate files from separate
/// encoder configurations, and one regenerated at another size must fail on its own
/// leg rather than silently redefine the other's geometry. Same reason
/// [`DISPLAY_H264`] and [`DISPLAY_H265`] are two constants holding 320x240.
const LOWDELAY_H265_FRAME_COUNT: usize = 120;
const DISPLAY_LOWDELAY_H265: (u32, u32) = (640, 480);
/// The Main 10 vector is 50 display frames.
const MAIN10_FRAME_COUNT: usize = 50;
@@ -690,6 +814,18 @@ fn assert_bit_identical(hashes: &[String], goldens: &[&str], codec: &str) {
/// information — and running one body twice is what makes that an equality
/// rather than two similar-looking assertions that could drift apart.
fn h264_parity_run(aus: &[&[u8]], label: &str) {
h264_parity_run_against(aus, label, GOLDENS_H264, FRAME_COUNT, DISPLAY_H264);
}
/// [`h264_parity_run`] with its stream's own goldens and geometry, for the legs that
/// do not decode the vendored vector.
fn h264_parity_run_against(
aus: &[&[u8]],
label: &str,
goldens: &'static str,
frame_count: usize,
display: (u32, u32),
) {
// One codec at a time on the device, and the `set_var` below happens only
// under this lock (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
@@ -698,10 +834,10 @@ fn h264_parity_run(aus: &[&[u8]], label: &str) {
// images grow TRANSFER_SRC so vkCmdCopyImageToBuffer is legal.
std::env::set_var("PF_VKD_TEST_READBACK", "1");
let goldens = golden_hashes(GOLDENS_H264);
let goldens = golden_hashes(goldens);
assert_eq!(
goldens.len(),
FRAME_COUNT,
frame_count,
"the golden file carries one hash per libavcodec frame"
);
@@ -729,7 +865,7 @@ fn h264_parity_run(aus: &[&[u8]], label: &str) {
setup.pd,
&setup.device,
setup.graphics_qf,
DISPLAY_H264,
display,
EXPECTED_FORMAT,
)
};
@@ -770,6 +906,28 @@ fn h264_four_byte_start_codes_decode_bit_identically() {
);
}
/// The leg the vendored vector cannot be: **our own host's low-delay H.264**.
///
/// The vector above passed 250/250 on every driver in the fleet while this rung
/// named one DPB slot as both `pSetupReferenceSlot` and a reference on 117 of the
/// 120 access units below — the shape it simply never produces (see
/// [`LOWDELAY_H264`]). Both of this rung's DPB modes take it badly and neither
/// loudly: DISTINCT hands the aliased reference the same array layer the setup
/// writes; COINCIDE finds no bound image for it, drops it from `pReferenceSlots` and
/// `trace!`s. So a leg that decodes what we actually ship is not redundant with the
/// conformance leg, it is the only one that can see this class at all.
#[test]
#[ignore = "needs a Vulkan Video H.264 decode device (fleet boxes; see module docs)"]
fn low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec() {
h264_parity_run_against(
&common::split_h264_aus(LOWDELAY_H264),
"H.264 (low-delay host stream)",
GOLDENS_LOWDELAY,
LOWDELAY_FRAME_COUNT,
DISPLAY_LOWDELAY,
);
}
/// The H.265 twin of [`h264_parity_run`]; see its docs for why the AUs are a
/// parameter.
fn h265_parity_run(
@@ -875,6 +1033,39 @@ fn main10_every_frame_hashes_bit_identical_to_libavcodec() {
);
}
/// The HEVC twin of [`low_delay_host_h264_every_frame_hashes_bit_identical_to_libavcodec`]:
/// **our own host's HEVC**, in the shape the vendored vector cannot produce.
///
/// Its job is the opposite of the H.264 leg's. That one exists because the rung was
/// broken and only this stream shape could show it. This one exists because until now
/// no HEVC leg anywhere had decoded a single frame our own encoder produced: both
/// existing legs run vendored vectors, and the H.264 sibling is the standing proof
/// that a vector's silence about a stream shape is not evidence.
///
/// What it exercises that `h265_every_frame_hashes_bit_identical_to_libavcodec` does
/// not: a five-picture DPB with four references marked and no reordering, so the
/// `SlotMap` retires and reissues a slot on 115 of the 120 access units, back to back,
/// with the decode target taking the slot freed in the same access unit. The vector
/// reorders, which keeps that eviction slack and never puts the two together.
///
/// It is NOT the leg that would catch a moved `dpb_snapshot()` — see [`LOWDELAY_H265`]
/// for why that lands on the DXVA rung instead, and
/// [`the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable`]
/// for the guard that keeps the planner property itself pinned, on CPU, in ordinary CI.
#[test]
#[ignore = "needs a Vulkan Video H.265 decode device (fleet boxes; see module docs)"]
fn low_delay_host_h265_every_frame_hashes_bit_identical_to_libavcodec() {
h265_parity_run(
&common::split_h265_aus(LOWDELAY_H265),
GOLDENS_LOWDELAY_H265,
LOWDELAY_H265_FRAME_COUNT,
0,
EXPECTED_FORMAT,
DISPLAY_LOWDELAY_H265,
"H.265 (low-delay host stream)",
);
}
/// The HEVC leg of the production prefix form — the one that would have caught
/// the shipped defect. See [`h264_four_byte_start_codes_decode_bit_identically`].
#[test]
@@ -903,24 +1094,52 @@ fn h265_four_byte_start_codes_decode_bit_identically() {
/// `show_existing_frame`) is the point at which this should grow the same parameters
/// the H.265 body carries — not before.
fn av1_parity_run(aus: &[&[u8]], label: &str) {
av1_parity_run_against(
aus,
label,
GOLDENS_AV1,
"data/test-25fps-av1.nv12.sha256",
FRAME_COUNT,
FRAME_COUNT,
DISPLAY_AV1,
);
}
/// [`av1_parity_run`] with its stream's own goldens and geometry, for the leg that
/// does not decode the vendored vector.
///
/// `units` and `frames` are SEPARATE parameters and must stay so. They are equal for
/// the low-delay host stream (one shown frame per temporal unit) and unequal for the
/// vendored vector only in the sense that its 250 units carry 274 coded frames of
/// which 250 are shown — deriving either from the other is exactly the assumption
/// AV1 punishes.
fn av1_parity_run_against(
aus: &[&[u8]],
label: &str,
goldens_file: &'static str,
goldens_path: &str,
units: usize,
frames: usize,
display: (u32, u32),
) {
// As the other legs: one codec at a time on the device, and the `set_var` below
// happens only under this lock (see `common::gpu_lock`).
let _gpu = common::gpu_lock();
std::env::set_var("PF_VKD_TEST_READBACK", "1");
let goldens = golden_hashes(GOLDENS_AV1);
let goldens = golden_hashes(goldens_file);
// Non-vacuity, before any hardware is touched: the right number of entries, all
// real digests, all distinct (see the helper's docs — a frozen-frame decoder
// must not be able to pass this leg).
assert_goldens_are_a_real_set(&goldens, FRAME_COUNT, "data/test-25fps-av1.nv12.sha256");
assert_goldens_are_a_real_set(&goldens, frames, goldens_path);
// …and the leg must actually be fed something. An IVF whose packets failed to
// parse would hand `collect_hashes` an empty AU list, which delivers no frames
// and would then fail as a frame-count mismatch that reads like a decoder defect.
assert_eq!(
aus.len(),
FRAME_COUNT,
"{label}: the vector must split into {FRAME_COUNT} temporal units"
units,
"{label}: the stream must split into {units} temporal units"
);
let setup = common::bring_up(&common::Request {
@@ -957,7 +1176,7 @@ fn av1_parity_run(aus: &[&[u8]], label: &str) {
setup.pd,
&setup.device,
setup.graphics_qf,
DISPLAY_AV1,
display,
EXPECTED_FORMAT,
)
};
@@ -1001,6 +1220,33 @@ fn av1_every_frame_hashes_bit_identical_to_libavcodec() {
av1_parity_run(&common::split_av1_aus(common::TEST_25FPS_AV1), "AV1");
}
/// **Our own host's AV1, at the only resolution where it emits more than one tile.**
///
/// The leg above proves the conversion against a vector with `tile_cols = tile_rows
/// = 1` on every one of its 274 frames, so every tile-info field it exercises is the
/// degenerate case: one `width_in_sbs_minus_1`, one `height_in_sbs_minus_1`, one
/// `context_update_tile_id`, `TileCols = TileRows = 1`. This stream carries
/// `tile_rows = 2` with `height_in_sbs_minus_1 = [16, 16]` on all 60 frames, and both
/// tiles arrive in a single Tile Group OBU — so a conversion that got the tile arrays,
/// the per-tile sizing or the tile-group range wrong would decode the vector perfectly
/// and this stream visibly (see [`LOWDELAY_AV1`]).
///
/// It is also 4K, which no other parity leg in this program is: the readback moves
/// 12,441,600 bytes per frame instead of 115,200.
#[test]
#[ignore = "needs a Vulkan Video AV1 decode device (fleet boxes; see module docs)"]
fn low_delay_host_av1_every_frame_hashes_bit_identical_to_libavcodec() {
av1_parity_run_against(
&common::split_av1_aus(LOWDELAY_AV1),
"AV1 (low-delay host stream, 4K two-tile)",
GOLDENS_LOWDELAY_AV1,
"data/lowdelay-3840x2160-av1.nv12.sha256",
LOWDELAY_AV1_UNIT_COUNT,
LOWDELAY_AV1_FRAME_COUNT,
DISPLAY_LOWDELAY_AV1,
);
}
/// Frame 0's pixels against libavcodec's, byte for byte — the diagnostic leg.
///
/// [`av1_every_frame_hashes_bit_identical_to_libavcodec`] is the verdict; this is
@@ -1598,6 +1844,415 @@ fn h264_goldens_and_au_split_agree_with_the_planner() {
);
}
/// The low-delay stream's own CPU guard, plus the property that makes it worth
/// vendoring at all.
///
/// The goldens/AU/output agreement is the same three-way check
/// [`h264_goldens_and_au_split_agree_with_the_planner`] does. What is extra here is
/// the last assertion: this stream must actually REACH the aliasing precondition —
/// a picture removed by the same access unit whose `dpb_refs` still names it — on
/// nearly every access unit. If a re-generation ever produced a stream that did not,
/// the GPU leg above would still pass 120/120 while proving nothing the vendored
/// vector does not already prove, and nothing else would say so.
#[test]
fn the_low_delay_stream_agrees_with_its_goldens_and_still_exercises_the_aliasing_shape() {
use pf_bitstream::h264::H264Planner;
let goldens = golden_hashes(GOLDENS_LOWDELAY);
assert_goldens_are_a_real_set(
&goldens,
LOWDELAY_FRAME_COUNT,
"data/lowdelay-640x480.nv12.sha256",
);
let aus = common::split_h264_aus(LOWDELAY_H264);
assert_eq!(aus.len(), LOWDELAY_FRAME_COUNT);
let mut planner = H264Planner::new();
let mut outputs = 0usize;
let mut both = 0usize;
let mut first_sps = None;
for (index, au) in aus.iter().enumerate() {
let plan = planner
.plan_au(au)
.unwrap_or_else(|e| panic!("AU {index}: the low-delay stream must plan, got {e:?}"));
outputs += plan.dpb.outputs.len();
both += plan
.dpb
.removed
.iter()
.filter(|id| plan.dpb_refs.iter().any(|r| r.id == **id))
.count();
first_sps.get_or_insert((
plan.sps.max_num_ref_frames,
plan.picture.max_dpb_frames,
plan.sps.vui_parameters.max_num_reorder_frames,
));
}
outputs += planner.flush().outputs.len();
assert_eq!(
outputs,
goldens.len(),
"the planner outputs {outputs} pictures but the goldens carry {} hashes",
goldens.len()
);
// The three SPS facts that make the shape reachable, pinned so a regenerated
// stream from a different encoder cannot quietly stop being low-delay.
assert_eq!(
first_sps,
Some((3, 3, 0)),
"max_num_ref_frames, DPB depth and max_num_reorder_frames — a DPB exactly as \
deep as the reference count, with no reordering, is what puts the unmarking \
and the eviction in one access unit"
);
assert_eq!(
both, 117,
"the stream must still remove pictures its own reference lists name — that is \
the ONLY reason it is vendored, and without it the GPU leg is a duplicate of \
the conformance one"
);
}
/// The HEVC low-delay stream's CPU guard — the twin of the H.264 one above, with the
/// extra assertion HEVC needs and H.264 does not.
///
/// H.264's guard pins that the stream still ALIASES (117 of 120), because its GPU leg
/// exists to catch a defect. HEVC's leg exists to keep an exemption from rotting, so
/// pinning `both == 0` alone would be exactly the vacuous check `fd6241a2` called out:
/// zero is also what a stream that never removes anything reports, and what a stream
/// that reorders reports. So this pins three numbers instead:
///
/// - **115 access units remove a picture** — the stream reaches the DPB pressure at all;
/// - **0 of them intersect `dpb_refs`** — the exemption, measured;
/// - **115 of them WOULD intersect** a snapshot taken before `decode_rps`.
///
/// The third is what makes the second worth having. `pre_rps_marked(N)` is exact rather
/// than approximate: `begin_picture` runs `decode_rps` → `update_dpb_before_decoding` →
/// `dpb_snapshot`, and the only thing that happens between AU N-1's snapshot and AU N's
/// `decode_rps` is `finish_picture(N-1)` storing its picture marked short-term. So the
/// marked set AU N's RPS sees is exactly `dpb_refs(N-1) {stored(N-1)}`, which is what
/// `dpb_snapshot()` would have returned from the other side of that call.
#[test]
fn the_low_delay_h265_stream_agrees_with_its_goldens_and_keeps_the_exemption_falsifiable() {
use pf_bitstream::h265::H265Planner;
let goldens = golden_hashes(GOLDENS_LOWDELAY_H265);
assert_goldens_are_a_real_set(
&goldens,
LOWDELAY_H265_FRAME_COUNT,
"data/lowdelay-640x480-h265.nv12.sha256",
);
let aus = common::split_h265_aus(LOWDELAY_H265);
assert_eq!(aus.len(), LOWDELAY_H265_FRAME_COUNT);
let mut planner = H265Planner::new();
let mut outputs = 0usize;
let mut iraps = 0usize;
let mut with_removals = 0usize;
let mut both = 0usize;
let mut would_alias = 0usize;
let mut first_sps = None;
// The marked DPB as AU N's `decode_rps` finds it: AU N-1's snapshot plus the
// picture AU N-1 stored. See the doc comment for why this is exact.
let mut pre_rps_marked: Vec<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;