fix(client): the H.264 twin was real — every low-delay picture decoded into a surface it predicted from
The AV1 review round flagged the H.264 leg as "plausibly the same defect, traced in source, not reproduced" and deliberately did not touch it. It is reproduced now, and it is worse than the AV1 one: it fires on 297 of 300 access units of every stream a punktfunk host emits, at 720p, 1080p and 2160p alike, on BOTH the DXVA rung and the Vulkan one. **Decided on the CPU, no GPU needed.** `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 then evicts lands in both `dpb_refs` (which `RefFrameList` is built from) and `dpb.removed`. The conversion released the whole `removed` list and then assigned the decode target a slot; `SlotMap::assign` takes the lowest free slot, which is the one just vacated. `CurrPic = N` and `RefFrameList[k] = N`, in one submission. The two conditions have to coincide in ONE access unit, and low-delay H.264 is exactly what makes them: `max_num_reorder_frames = 0` means the evicted picture has already been output, which is what makes it evictable at all. 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 — so the window unmarks the oldest reference in the very unit whose bump drops 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. **Why two hardware-proven codecs and four GPUs never saw it.** `test-25fps.h264` 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 2 reference frames — the window unmarks two units before the bump can evict — and it REORDERS, which keeps an unmarked picture alive past the unit that unmarked it. Two independent reasons, both properties of that vector rather than of H.264. It measured zero and passed 250/250 throughout. `data/lowdelay-640x480.h264` is vendored to close exactly that: our own host's output, 120 pictures, goldens from libavcodec cross-checked bit-identical across two ffmpeg builds on two architectures. **The fix is the AV1 fix.** `DecodePlanDxva` and `DecodePlanVk` grow `release_after_decode`, the conversions hand the removals back instead of applying them, and the callers release them once the decode op is issued. It costs no slot the map does not have: `SlotMap::new` allocates `max_dpb_frames + 1` and the DPB never exceeds `max_dpb_frames`, so a free slot always exists with the whole `removed` list still held — measured, peak 4 of 4 on the stream that defers on 117 of 120 units. The Vulkan rung breaks on it in both DPB modes and neither loudly: DISTINCT hands the aliased reference the same array layer the setup writes; COINCIDE clears `slot_image[setup]` in the binding sync and the reference then resolves to no bound image, dropping out of `pReferenceSlots` with a `trace!`. Its deferred release runs on the FAILURE paths too — the fallible region's Result is held rather than `?`-ed, because seven exits sat between the conversion and the release and each would have leaked a slot. `a_full_dpb_bump_reuses_the_slot_but_the_pool_model_binds_a_fresh_image` asserted the aliasing as "the planner's normal behaviour": an authored depth-1 stream whose AU1 references the picture it evicts. It now asserts the opposite, which is the defect in two lines. New evidence, all of it runnable: the CPU proof pins BOTH numbers (0 on the vector, 117 of 120 on the low-delay stream) so neither can drift silently; the ledger-pressure test measures the peak; and a low-delay parity leg is added to `pf-vkdecode`'s `gpu_parity` and `pf-client-core`'s `video_d3d11_native::parity` so both rungs are held to what they stream rather than only to what they conform to.
This commit is contained in:
@@ -20,6 +20,19 @@
|
||||
//! * **H.264 and H.265** — frame-hash parity against libavcodec on an RTX 4090 and an AMD
|
||||
//! 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.
|
||||
//! * **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
|
||||
@@ -188,14 +201,19 @@ 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,
|
||||
/// AV1 only: surfaces this frame's own refresh displaces while the submission
|
||||
/// still NAMES them, released once the decode op has been issued.
|
||||
/// 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`]).
|
||||
///
|
||||
/// See [`pf_dxvadec::DecodePlanDxvaAv1::release_after_decode`] — this is the
|
||||
/// caller's half of that contract, and dropping it decodes 268 of the vendored
|
||||
/// vector's 274 frames into a surface they predict from. Always empty on
|
||||
/// H.264 and H.265, whose conversions release their whole `removed` list
|
||||
/// themselves (neither vendored vector ever produces the shape).
|
||||
/// 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,
|
||||
@@ -458,11 +476,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);
|
||||
@@ -474,6 +502,32 @@ 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: a deferred id that holds no slot
|
||||
// means the conversion and the ledger disagree about the DPB, which
|
||||
// is a bug in one of them rather than a stream this AU can do
|
||||
// anything about.
|
||||
tracing::warn!(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
|
||||
@@ -627,20 +681,7 @@ impl NativeD3d11Decoder {
|
||||
// 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.
|
||||
if let Some(session) = self.session.as_mut() {
|
||||
for &id in &sub.release_after_decode {
|
||||
if !session.slots.release(id) {
|
||||
// Never fatal, and never silent: a deferred id that holds no
|
||||
// slot means the conversion and the ledger disagree about the
|
||||
// store, which is a bug in one of them rather than a stream
|
||||
// this frame can do anything about.
|
||||
tracing::warn!(
|
||||
id,
|
||||
"AV1 deferred release named a picture holding no surface"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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.
|
||||
@@ -836,21 +877,20 @@ impl NativeD3d11Decoder {
|
||||
slice_ranges: dxva.slice_ranges,
|
||||
setup_slot: dxva.setup_slot,
|
||||
setup_id: dxva.setup_id,
|
||||
// ⚠ H.264's conversion still releases its whole `removed` list
|
||||
// inside itself, and that is a MEASUREMENT rather than a proof.
|
||||
// `pf_dxvadec::pic`'s `no_au_removes_a_picture_its_own_reference_
|
||||
// list_names` pins `removed ∩ dpb_refs` at zero over the vendored
|
||||
// vector — but that vector REORDERS, and reordering is what keeps
|
||||
// an unmarked picture in the DPB past the AU that unmarked it. On
|
||||
// a low-delay stream, which is what a punktfunk host emits, the
|
||||
// sliding window can unmark an already-output picture and
|
||||
// `bump_as_needed` evict it in the same access unit, putting it in
|
||||
// both `RefFrameList` and `removed` — the AV1 aliasing shape,
|
||||
// exactly. Left alone deliberately: the AV1 defect is what this
|
||||
// change fixes and proves, and giving two hardware-proven codecs a
|
||||
// deferral no vector exercises would be an unmeasured change to
|
||||
// working code. The tripwire is the test named above.
|
||||
release_after_decode: Vec::new(),
|
||||
// ⚠⚠ 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),
|
||||
@@ -910,13 +950,15 @@ impl NativeD3d11Decoder {
|
||||
slice_ranges: dxva.slice_ranges,
|
||||
setup_slot: dxva.setup_slot,
|
||||
setup_id: dxva.setup_id,
|
||||
// HEVC is the one of the three where this 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
|
||||
// the aliasing shape cannot arise. (The AV1 planner snapshots
|
||||
// BEFORE, which is why that codec needed the deferral, and the
|
||||
// H.264 one snapshots before marking too — see the note above.)
|
||||
// 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 {
|
||||
@@ -1701,6 +1743,23 @@ 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");
|
||||
|
||||
@@ -2175,6 +2234,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);
|
||||
@@ -2545,6 +2609,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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+277
-81
@@ -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,66 +970,153 @@ mod tests {
|
||||
assert_eq!(converted.len(), 250);
|
||||
}
|
||||
|
||||
/// ⚠⚠ **TRIPWIRE, not a proof — and the thing it watches for is UNRESOLVED.**
|
||||
/// The hazard the vendored vector CANNOT see, on a stream that can.
|
||||
///
|
||||
/// This conversion releases the whole of `plan.dpb.removed` and then assigns the
|
||||
/// decode target a slot. [`SlotMap::assign`] takes the LOWEST FREE slot, which is
|
||||
/// the one just released — so if a picture is ever in BOTH `dpb_refs` (which is
|
||||
/// what `RefFrameList` is built from, above) and `dpb.removed`, the submission
|
||||
/// names one surface as `CurrPic` and as a `RefFrameList` entry at once, and the
|
||||
/// frame decodes into a picture it predicts from. That is precisely the defect
|
||||
/// measured on the AV1 leg on 2026-08-07: 245 of 250 delivered frames wrong on an
|
||||
/// Intel Arc, invisible on NVIDIA for 63 frames, and invisible on glass entirely.
|
||||
/// `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.
|
||||
///
|
||||
/// For AV1 it was fixed by deferring the release past the decode op. For H.264 it
|
||||
/// was NOT, because the intersection is empty on the vendored vector and changing
|
||||
/// a hardware-proven codec on an unreproduced suspicion is the worse risk. What
|
||||
/// this test does is make the assumption falsifiable instead of tacit.
|
||||
/// 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.
|
||||
///
|
||||
/// **Why zero here is weak evidence.** `H264Planner` snapshots `dpb_refs` in
|
||||
/// `begin_picture`, BEFORE `finish_picture` runs 8.2.5 marking and then bumps the
|
||||
/// DPB — and the vendored bump drops a picture the sliding window just unmarked
|
||||
/// only once it has been OUTPUT. This vector reorders (it has B-frames), so an
|
||||
/// unmarked picture is still awaiting output and lingers past the AU that unmarked
|
||||
/// it, which is exactly what keeps the intersection empty. **A punktfunk host emits
|
||||
/// low-delay H.264 with no reordering**, where a picture is output the moment it is
|
||||
/// decoded — the condition that puts eviction and unmarking in the same access
|
||||
/// unit. So the shape is plausibly live in the field and merely unreachable here.
|
||||
/// 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.
|
||||
///
|
||||
/// HEVC does not need this test and cannot be given one: `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.
|
||||
/// 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.
|
||||
///
|
||||
/// If this ever fires, the fix is `pic_av1.rs`'s: hand the removals back to the
|
||||
/// caller as `release_after_decode` and let it release them once the decode op is
|
||||
/// issued. Do not "fix" it by relaxing the count.
|
||||
/// 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 no_au_removes_a_picture_its_own_reference_list_names() {
|
||||
let mut both = 0usize;
|
||||
let mut aus_with_removals = 0usize;
|
||||
for (plan, _) in convert_stream() {
|
||||
if !plan.dpb.removed.is_empty() {
|
||||
aus_with_removals += 1;
|
||||
}
|
||||
for id in &plan.dpb.removed {
|
||||
if plan.dpb_refs.iter().any(|r| r.id == *id) {
|
||||
both += 1;
|
||||
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!(
|
||||
aus_with_removals > 0,
|
||||
"no access unit of this vector removed anything, so the intersection below \
|
||||
is empty for a reason that has nothing to do with the hazard"
|
||||
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,
|
||||
"{both} picture(s) are in this AU's reference list AND removed by it — the \
|
||||
conversion releases them before assigning the decode target a slot, so \
|
||||
`CurrPic` and a `RefFrameList` entry now name one surface and the frame \
|
||||
predicts from the picture it is writing. This is the AV1 defect of \
|
||||
2026-08-07 on the H.264 leg; fix it the same way (a deferred \
|
||||
`release_after_decode`), never by changing this number"
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1164,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,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;
|
||||
|
||||
+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);
|
||||
|
||||
+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));
|
||||
|
||||
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
|
||||
@@ -116,6 +116,29 @@ 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);
|
||||
|
||||
/// The Main 10 vector is 50 display frames.
|
||||
const MAIN10_FRAME_COUNT: usize = 50;
|
||||
|
||||
@@ -690,6 +713,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 +733,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 +764,7 @@ fn h264_parity_run(aus: &[&[u8]], label: &str) {
|
||||
setup.pd,
|
||||
&setup.device,
|
||||
setup.graphics_qf,
|
||||
DISPLAY_H264,
|
||||
display,
|
||||
EXPECTED_FORMAT,
|
||||
)
|
||||
};
|
||||
@@ -770,6 +805,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(
|
||||
@@ -1598,6 +1655,76 @@ 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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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