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
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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -137,6 +137,36 @@ pub(super) fn subframe_env_forced() -> bool {
|
||||
/// drop-in alternative — `poll_chunk` cuts at `bitstreamSizeInBytes` on the reasoning that
|
||||
/// "slices are contiguous Annex-B", which AV1's OBUs are not.
|
||||
///
|
||||
/// # A tile-aware chunk reader was considered and is CLOSED, not deferred
|
||||
///
|
||||
/// The obvious follow-up is to teach the reader AV1's units — cut on OBU boundaries instead
|
||||
/// of byte counts and arm from the driver's reported unit count — so AV1 gets the sub-frame
|
||||
/// latency win HEVC gets (ship tile 1 while tile 2 encodes). Measured on `.21` (RTX 5070 Ti,
|
||||
/// `av1_nvenc`, 2026-08-07) before writing any of it, and the measurement closes it:
|
||||
///
|
||||
/// * **4K carries two tiles, and they share ONE Tile Group OBU.** The frame header reads
|
||||
/// `width_in_sbs_minus_1[0] = 59` (one tile column, the full 3840) and
|
||||
/// `height_in_sbs_minus_1[] = {16, 16}` (two tile rows) — but
|
||||
/// `tile_start_and_end_present_flag = 0`, which puts both tiles in a single Tile Group
|
||||
/// OBU. There is no OBU boundary between them to cut on. Shipping tile 1 early would mean
|
||||
/// the HOST re-authoring AV1 syntax per chunk — synthesising a fresh Tile Group OBU header
|
||||
/// with `tile_start_and_end_present_flag = 1` and its own `tg_start`/`tg_end` — which is
|
||||
/// bitstream surgery on the encode path, not a reader change.
|
||||
/// * **1080p carries one tile** (`tile_cols_log2 = tile_rows_log2 = 0`), so there is nothing
|
||||
/// to pipeline at the commonest streaming resolution regardless.
|
||||
/// * **The prize is small even at 4K, because split encode already spent it.** The two tile
|
||||
/// rows go to two split-encode engines that run CONCURRENTLY, so they finish at nearly the
|
||||
/// same moment: the win is bounded by the skew between engines, not by half the frame.
|
||||
/// Whole-frame encode measures 3.3–3.6 ms at 4K60 against a 16.7 ms p50 end-to-end, so
|
||||
/// even the sequential-tiles fantasy caps out near 1.7 ms and the real figure is a
|
||||
/// fraction of that. HEVC's sub-frame win is larger for a structural reason that does not
|
||||
/// transfer: forced split and sub-frame are mutually unsupported (below), so HEVC's slices
|
||||
/// really are produced one after another.
|
||||
///
|
||||
/// Reopen only if NVENC starts emitting one OBU per tile, or sets
|
||||
/// `tile_start_and_end_present_flag = 1` — at that point the cut points exist and the reader
|
||||
/// change becomes the small piece it was assumed to be.
|
||||
///
|
||||
/// Returns the `(split_mode, subframe)` to ACTUALLY configure. The caller must store BOTH back
|
||||
/// (the chunked-poll latch and `CeilingKey` key on them) — a silent in-params drop would leave
|
||||
/// `poll_chunk` busy-polling its full budget every AU (`numSlices` stays 0 without
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user