The grey native-vulkan stream: a host re-anchor claim the client could not check #262

Merged
enricobuehler merged 3 commits from worktree-grey-frame-reanchor-fixes into main 2026-08-16 07:57:21 +00:00
24 changed files with 1613 additions and 113 deletions
+92 -3
View File
@@ -277,6 +277,19 @@ pub struct PicturePlan {
/// Colour signalling, per picture and never latched — the same rule the other two
/// planners follow, because a host can switch an HDR desktop to PQ/BT.2020 in band.
pub colour: ColourDescription,
/// Every picture this frame predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this frame is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for a key or intra-only frame (nothing to predict from) and for any
/// frame whose whole reference chain is clean; `false` from the moment this frame
/// — or anything it descends from — needed concealment.
///
/// On a `show_existing_frame` this describes the picture being DISPLAYED, which is
/// the only thing such a frame puts on the screen: it decodes nothing of its own.
///
/// Purely additive observation. See [`crate::clean`] for why it propagates and why
/// every rule errs toward `false`.
pub references_clean: bool,
}
/// One planned access unit.
@@ -329,6 +342,35 @@ pub enum PlanWarning {
TruncatedAu { offset: usize },
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged? The AV1 twin of
/// [`crate::h264::PlanWarning::is_integrity`], and
/// `pf_vkdecode::is_integrity_warning_av1` delegates here.
///
/// Every variant AV1 has IS damage, and that is a fact about the codec rather than
/// an oversight: AV1 puts nothing in this channel resembling h265's
/// `NonZeroReorder` or h264's `Mmco5Rebase`. It has no reorder envelope to report
/// (no bumping process, no `max_num_reorder_pics`) and no MMCO to rebase — the
/// frame header states the whole reference update outright — so the only things
/// left to warn about are pictures that went missing and an OBU walk that stopped
/// early.
///
/// `MissingShowExisting` is the one that could be argued, and it is damage: a
/// `show_existing_frame` naming an empty slot means the picture the STREAM chose
/// to display was lost upstream. Nothing is displayed for that frame, so the
/// screen keeps the previous one — exactly the "silently stale picture" state a
/// re-anchor exists to end.
///
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::MissingReference { .. }
| PlanWarning::MissingShowExisting { .. }
| PlanWarning::TruncatedAu { .. } => true,
}
}
}
/// Why an access unit cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -366,6 +408,10 @@ pub struct Av1Planner {
slots: [Option<RefPic>; NUM_REF_SLOTS],
next_id: PicId,
sequence: Option<Rc<SequenceHeaderObu>>,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl Default for Av1Planner {
@@ -381,6 +427,7 @@ impl Av1Planner {
slots: [None; NUM_REF_SLOTS],
next_id: 1,
sequence: None,
clean: Default::default(),
}
}
@@ -549,7 +596,22 @@ impl Av1Planner {
} else {
Vec::new()
};
let picture = picture_plan(&header, &sequence);
// A `show_existing_frame` decodes nothing, so the only picture it puts on
// the screen is the one it displays: report THAT picture's cleanliness. A
// slot that held nothing already warned above and shows nothing at all,
// which is damage in its own right — reporting it unclean keeps the two
// statements consistent.
let references_clean = match shown {
Some(pic) => self.clean.references_clean([pic.id]),
None => false,
};
let picture = picture_plan(&header, &sequence, references_clean);
// A key-frame `show_existing_frame` rewrote every slot with the shown
// picture (7.20), so the ledger has to follow that aliasing: the refreshed
// slots all hold `pic.id`, whose mark already stands. Nothing new is
// stored, so there is no verdict to fold — only residency to re-bound.
self.clean
.retain_live(self.slots.iter().flatten().map(|p| p.id));
return Ok(AuPlan {
picture,
tiles,
@@ -597,12 +659,34 @@ impl Av1Planner {
}
let removed = self.refresh_slots(header.refresh_frame_flags, id, RefState::of(&header));
let picture = picture_plan(&header, &sequence);
// Was every picture this frame predicts from decoded off an intact chain?
// Over the resolved names only: a `None` hole is a lost reference, which has
// already pushed `MissingReference` and therefore condemns this frame through
// `concealed` below. A key or intra-only frame names nothing, so this is
// vacuously true for it (`CleanLedger::references_clean`).
let references_clean = self
.clean
.references_clean(refs.iter().flatten().map(|r| r.id));
let picture = picture_plan(&header, &sequence, references_clean);
let outputs = if header.show_frame {
vec![id]
} else {
Vec::new()
};
// Fold this frame's verdict, then bound the ledger to slot residency. After
// `refresh_slots`, so the live set reflects the writes this frame performed.
// `concealed` mirrors what a consumer conceals on, via the ONE classification
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
// disagree about whether this frame was damaged.
self.clean.note_stored(
id,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean
.retain_live(self.slots.iter().flatten().map(|p| p.id));
Ok(AuPlan {
picture,
tiles,
@@ -655,7 +739,11 @@ impl Av1Planner {
}
}
fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> PicturePlan {
fn picture_plan(
header: &FrameHeaderObu,
sequence: &SequenceHeaderObu,
references_clean: bool,
) -> PicturePlan {
let color = &sequence.color_config;
let bit_depth = if color.high_bitdepth {
if color.twelve_bit {
@@ -698,6 +786,7 @@ fn picture_plan(header: &FrameHeaderObu, sequence: &SequenceHeaderObu) -> Pictur
matrix_coefficients: color.matrix_coefficients as u8,
video_full_range: color.color_range,
},
references_clean,
}
}
#[cfg(test)]
+245
View File
@@ -0,0 +1,245 @@
//! Which decoded pictures came off a fully-available reference chain — the fact a
//! client needs to CORROBORATE a host's claim that a frame is a clean re-anchor.
//!
//! # Why this exists
//!
//! After a loss the client freezes on its last good picture and lifts only on a proven
//! re-anchor. One of those proofs — `USER_FLAG_RECOVERY_ANCHOR`, the host's LTR-RFI
//! recovery frame — lifts the freeze on its FIRST occurrence, exactly like a real IDR,
//! because the host says the frame was coded against a known-good reference.
//!
//! The host's "known-good" is an inference from what the client RECEIVED. The client's
//! own DPB is the only place that knows what it actually DECODED, and it did not
//! previously record it: a picture the planner concealed (a reference the DPB could not
//! resolve, an AU that stopped early) entered the DPB looking exactly like a clean one.
//! So an anchor naming that picture lifted the freeze onto a gray plate, and every
//! frame after it chained off the corruption — the freeze gone, nothing left to
//! re-arm it, and the picture stayed broken until an unrelated signal forced an IDR.
//!
//! This ledger is the missing fact, and it is deliberately the SMALLEST one that
//! answers the question: a set of picture ids that are NOT clean. Membership is
//! per-picture, so it costs one `u64` per damaged picture and nothing at all on a
//! healthy stream — the overwhelmingly common case, where the set stays empty for the
//! life of the session.
//!
//! # Damage propagates; that is the whole point
//!
//! A picture is unclean when the AU that produced it needed concealment, OR when
//! ANYTHING it predicted from was unclean. Without the second half the ledger would be
//! useless: the concealed picture itself is rarely the one an anchor names — it is the
//! chain of ordinary P-frames DESCENDING from it, each of which planned perfectly and
//! raised no warning of its own, that carries the corruption forward.
//!
//! # It errs toward "unclean", never toward "clean"
//!
//! Every rule here is one-way. An id the ledger has forgotten (evicted from the DPB,
//! dropped at a flush) reads as clean, which is correct — a picture no longer in the
//! DPB cannot be referenced. An id it holds stays unclean until the picture leaves the
//! DPB. There is no path that clears the mark on a picture that is still resident, so
//! the ledger can only ever make a consumer MORE conservative: hold the freeze longer
//! and take an IDR it might not have needed. The opposite mistake — reporting a damaged
//! chain as clean — is the failure this exists to end, so the asymmetry is deliberate.
use std::collections::BTreeSet;
/// Per-picture "this came off a broken chain" marks for one planner.
///
/// Keyed by the planner's own `PicId` (a `u64` in all three codecs), so this type is
/// codec-agnostic and the H.264, H.265 and AV1 planners share ONE implementation rather
/// than three hand-copies that can drift apart.
#[derive(Debug, Clone, Default)]
pub struct CleanLedger {
/// Ids of resident pictures that are NOT clean. Empty on a healthy stream — the
/// set only ever gains an entry when a plan needed concealment.
unclean: BTreeSet<u64>,
}
impl CleanLedger {
pub fn new() -> Self {
Self::default()
}
/// Is every id in `references` clean? — i.e. may a picture predicted from exactly
/// these be trusted?
///
/// Vacuously true for an empty list, which is what makes an IRAP/IDR clean by
/// construction: it predicts from nothing, so there is nothing to distrust.
pub fn references_clean<I>(&self, references: I) -> bool
where
I: IntoIterator<Item = u64>,
{
// Short-circuits on the first unclean reference, and — because the set is
// empty on a healthy stream — degenerates to one `is_empty`-cheap lookup per
// reference in the case that matters for throughput.
self.unclean.is_empty() || !references.into_iter().any(|id| self.unclean.contains(&id))
}
/// Record the verdict for the picture this AU stored.
///
/// `references_clean` is what [`Self::references_clean`] answered for this AU's
/// reference lists; `concealed` is whether the AU's own plan carried an integrity
/// warning. Either one being bad makes the stored picture unclean, and its
/// descendants inherit that through their own `references_clean` call.
pub fn note_stored(&mut self, id: u64, references_clean: bool, concealed: bool) {
if references_clean && !concealed {
// The common path. Nothing is inserted, so a healthy stream never allocates
// — and `remove` still runs below because an id can be REUSED after the
// planner recycles it, and a stale mark would then condemn a fresh picture.
self.unclean.remove(&id);
} else {
self.unclean.insert(id);
}
}
/// Drop the marks of pictures that have left the DPB.
///
/// Called with the ids still live after each plan. Bounding the set to DPB
/// residency is what keeps it from growing without limit across a long lossy
/// session, and it is safe precisely because a picture outside the DPB can never
/// appear in a later reference list.
pub fn retain_live<I>(&mut self, live: I)
where
I: IntoIterator<Item = u64>,
{
if self.unclean.is_empty() {
return;
}
let live: BTreeSet<u64> = live.into_iter().collect();
self.unclean.retain(|id| live.contains(id));
}
/// Forget everything — the DPB was drained (a flush, a stream discontinuity), so no
/// mark describes a resident picture any more.
pub fn clear(&mut self) {
self.unclean.clear();
}
/// Is this picture known to have come off a broken chain? (Diagnostics and tests;
/// the plan path uses [`Self::references_clean`].)
pub fn is_unclean(&self, id: u64) -> bool {
self.unclean.contains(&id)
}
/// How many resident pictures are marked unclean (diagnostics and tests).
pub fn unclean_count(&self) -> usize {
self.unclean.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The headline: damage propagates down the prediction chain. The concealed
/// picture is rarely the one an anchor names — it is the ordinary P-frames
/// descending from it, each of which planned perfectly and warned about nothing.
#[test]
fn damage_propagates_to_every_descendant() {
let mut led = CleanLedger::new();
// An IDR: no references, no concealment.
assert!(led.references_clean([]));
led.note_stored(0, true, false);
assert!(!led.is_unclean(0));
// A clean P off it.
assert!(led.references_clean([0]));
led.note_stored(1, true, false);
// Picture 2's plan needed concealment.
let refs_clean = led.references_clean([1]);
assert!(refs_clean, "its reference was still fine");
led.note_stored(2, refs_clean, true);
assert!(led.is_unclean(2));
// …and picture 3 predicts from it, raising NO warning of its own.
let refs_clean = led.references_clean([2]);
assert!(!refs_clean, "the chain is broken from here down");
led.note_stored(3, refs_clean, false);
assert!(led.is_unclean(3), "3 inherited 2's damage");
// The rot keeps travelling, arbitrarily far from the original loss.
let refs_clean = led.references_clean([3]);
assert!(!refs_clean);
led.note_stored(4, refs_clean, false);
assert!(led.is_unclean(4));
}
/// A picture that references BOTH a clean and an unclean predecessor is unclean —
/// one broken reference is enough to make the reconstruction wrong.
#[test]
fn one_unclean_reference_is_enough() {
let mut led = CleanLedger::new();
led.note_stored(0, true, false);
led.note_stored(1, true, true); // damaged
assert!(!led.references_clean([0, 1]));
assert!(!led.references_clean([1, 0]), "order does not matter");
assert!(led.references_clean([0]));
}
/// An IDR predicts from nothing, so it is clean however broken the stream was
/// before it. This is the property that lets a real keyframe end a damaged run.
#[test]
fn a_picture_with_no_references_is_clean_however_bad_the_stream_was() {
let mut led = CleanLedger::new();
led.note_stored(0, true, true);
led.note_stored(1, false, false);
assert_eq!(led.unclean_count(), 2);
// The IDR: an empty reference list is vacuously clean.
assert!(led.references_clean([]));
led.note_stored(2, true, false);
assert!(!led.is_unclean(2));
}
/// Marks are bounded by DPB residency: a picture that left the DPB can never be
/// referenced again, so keeping its mark would only grow the set forever.
#[test]
fn marks_are_dropped_when_their_picture_leaves_the_dpb() {
let mut led = CleanLedger::new();
led.note_stored(7, true, true);
led.note_stored(8, false, false);
assert_eq!(led.unclean_count(), 2);
led.retain_live([8, 9]);
assert!(!led.is_unclean(7), "7 was evicted");
assert!(led.is_unclean(8), "8 is still resident and still damaged");
assert_eq!(led.unclean_count(), 1);
}
/// A flush drains the whole DPB, so no mark describes anything resident.
#[test]
fn a_flush_forgets_every_mark() {
let mut led = CleanLedger::new();
led.note_stored(1, true, true);
led.note_stored(2, false, false);
led.clear();
assert_eq!(led.unclean_count(), 0);
assert!(led.references_clean([1, 2]));
}
/// Planners hand out ids from a counter the flush path can rewind, so an id CAN be
/// reused. A stale mark must not condemn the fresh picture that inherits the id.
#[test]
fn a_reused_id_is_not_condemned_by_its_predecessors_mark() {
let mut led = CleanLedger::new();
led.note_stored(5, true, true);
assert!(led.is_unclean(5));
// The same id, planned cleanly this time.
led.note_stored(5, true, false);
assert!(!led.is_unclean(5));
assert!(led.references_clean([5]));
}
/// A healthy stream never marks anything, forever — the property that makes this
/// free to carry on every session that is working correctly.
#[test]
fn a_stream_without_loss_never_marks_a_picture() {
let mut led = CleanLedger::new();
for id in 0..512u64 {
let refs = if id == 0 { vec![] } else { vec![id - 1] };
let clean = led.references_clean(refs.iter().copied());
assert!(clean, "picture {id} must read clean");
led.note_stored(id, clean, false);
led.retain_live(id.saturating_sub(3)..=id);
}
assert_eq!(led.unclean_count(), 0);
}
}
+190 -2
View File
@@ -149,6 +149,18 @@ pub struct PicturePlan {
/// DPB size in frames per A.3.1 — backends size their slot pool from this.
pub max_dpb_frames: usize,
pub recovery_point: Option<RecoveryPoint>,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this AU is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for an IDR (nothing to predict from) and for any picture whose whole
/// reference chain is clean; `false` from the moment this AU — or anything it
/// descends from — needed concealment.
///
/// Purely additive observation: nothing in the plan, the warnings or the DPB
/// changes because of it, and on a stream that never loses a reference it is
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
/// why every rule errs toward `false`.
pub references_clean: bool,
}
/// The region of the coded picture that is actually displayed.
@@ -252,6 +264,41 @@ pub enum PlanWarning {
},
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged — the plan was completed with a
/// SUBSTITUTE in place of something that was lost — rather than reporting a
/// spec-legal fact about the stream's envelope?
///
/// The distinction decides two things that must never disagree: whether a consumer
/// releases the AU's output unshown and asks for a re-anchor, and whether the
/// picture enters [`crate::clean::CleanLedger`] as unclean. It lives HERE, on the
/// enum, because those two consumers sit in different crates and a second copy of
/// the list would let one of them conceal damage the other reports — the exact
/// shape of the invisible-corruption failure the native-decode program exists to
/// end. `pf_vkdecode::is_integrity_warning` delegates to this.
///
/// `Mmco5Rebase` is not damage: the AU carried an MMCO 5 and this planner planned
/// it in full (the plan holds the pre-rebase 8.2.1 values; later AUs reference the
/// rebased ones). `LevelDerivedDpb` is 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, a property of the STREAM's signalling which
/// a backend 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 variant
/// added later — by definition one nobody here has classified — would be reported
/// as clean and its picture shown. The compiler is the only reviewer guaranteed to
/// be present when that variant is written, so it gets the decision.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::FrameNumGap { .. }
| PlanWarning::MissingReference { .. }
| PlanWarning::TruncatedAu { .. } => true,
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
}
}
}
/// The AU cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -482,6 +529,10 @@ pub struct H264Planner {
reported_live: BTreeSet<PicId>,
/// Set by [`Self::flush`]: planning resumes only at an IDR (upstream: `Reset`).
awaiting_idr: bool,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl H264Planner {
@@ -600,9 +651,20 @@ impl H264Planner {
let cur = current
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
// Was every picture this AU predicts from decoded off an intact chain? Asked
// over the SLICE reference lists rather than the DPB snapshot, because those
// are what this picture actually predicts from — a resident-but-unreferenced
// damaged picture says nothing about this one. An IDR references nothing, so
// this is vacuously true for it (`CleanLedger::references_clean`).
let references_clean = self.clean.references_clean(
slices
.iter()
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
.map(|r| r.id),
);
// Captured before finish_picture: MMCO5 rewrites the stored POC afterwards, but
// backends submit the picture with its 8.2.1 values.
let picture = Self::picture_plan(&cur, recovery_point);
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
// The activated parameter sets ride out with the plan (AuPlan field docs);
// cloned before finish_picture consumes `cur`.
let pps = Rc::clone(&cur.first_slice_pps);
@@ -619,6 +681,20 @@ impl H264Planner {
let removed = previously_live.difference(&live_after).copied().collect();
self.reported_live = live_after;
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
// AFTER `finish_picture`, so `stored` is the id the picture really got and
// `live_after` reflects the marking this AU performed — a mark written against
// a pre-marking view could survive an eviction it should have died with.
// `concealed` mirrors what a consumer conceals on, via the ONE classification
// (`PlanWarning::is_integrity`), so the ledger and the consumer can never
// disagree about whether this AU was damaged.
self.clean.note_stored(
stored,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean.retain_live(self.reported_live.iter().copied());
Ok(AuPlan {
picture,
slices,
@@ -650,6 +726,9 @@ impl H264Planner {
self.max_long_term_frame_idx = Default::default();
self.negotiation_info = Default::default();
self.awaiting_idr = true;
// The DPB is drained, so no mark describes a resident picture any more — and
// planning resumes at an IDR, which is clean by construction.
self.clean.clear();
DpbUpdate {
stored: None,
@@ -1747,7 +1826,11 @@ impl H264Planner {
Ok(id)
}
fn picture_plan(cur: &CurrentPicState, recovery_point: Option<RecoveryPoint>) -> PicturePlan {
fn picture_plan(
cur: &CurrentPicState,
recovery_point: Option<RecoveryPoint>,
references_clean: bool,
) -> PicturePlan {
let pic = &cur.pic;
// The first slice's PPS defines the picture's parameters (upstream's
// start_picture semantics); `cur.pps` may have drifted to a later slice's.
@@ -1791,6 +1874,7 @@ impl H264Planner {
chroma_format_idc: sps.chroma_format_idc,
max_dpb_frames: dpb_limit(sps),
recovery_point,
references_clean,
}
}
}
@@ -2343,6 +2427,110 @@ mod tests {
assert!(missing_seen);
}
/// The clean bit, end to end through the real planner: a `frame_num` gap
/// concealed one picture, and EVERY picture descending from it reports
/// `references_clean == false` even though their own plans are spotless. That
/// propagation is the whole point — the concealed picture is rarely the one a host
/// recovery anchor names; the ordinary P-frames after it are.
#[test]
fn a_concealed_picture_makes_every_descendant_report_unclean_references() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
let p0 = planner.plan_au(&au0).unwrap();
assert!(
p0.picture.references_clean,
"an IDR references nothing, so it is clean by construction"
);
// A healthy P off the IDR: still clean.
let p1 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
assert!(picture_warnings(&p1).is_empty());
assert!(p1.picture.references_clean);
// frame_num 2 never arrives — 8.2.5.2 fabricates a placeholder and the plan
// conceals. THIS picture's references were still intact; the damage is its own.
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
assert!(p3
.warnings
.iter()
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
// …and every picture after it inherits the damage with a clean plan of its own.
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
assert!(
picture_warnings(&p4).is_empty(),
"p4's own plan raises nothing — which is exactly why the bit is needed"
);
assert!(
!p4.picture.references_clean,
"p4 predicts from the concealed chain, so it must not read as clean"
);
let p5 = planner.plan_au(&write_p_slice(5, 10, 1, 1, None)).unwrap();
assert!(picture_warnings(&p5).is_empty());
assert!(!p5.picture.references_clean, "the rot keeps travelling");
}
/// An IDR ends a damaged run: it predicts from nothing, so it reads clean however
/// broken the stream was before it. Without this a session could never recover a
/// trustworthy anchor.
#[test]
fn an_idr_reports_clean_references_however_damaged_the_run_before_it() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
planner.plan_au(&au0).unwrap();
planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
// Gap: frame_num 2 lost.
let p3 = planner.plan_au(&write_p_slice(3, 6, 1, 3, None)).unwrap();
assert!(p3
.warnings
.iter()
.any(|w| matches!(w, PlanWarning::FrameNumGap { .. })));
let p4 = planner.plan_au(&write_p_slice(4, 8, 1, 1, None)).unwrap();
assert!(!p4.picture.references_clean);
// A fresh IDR re-anchors, and the pictures after it are clean again.
let mut idr = param_set_au(&sps, &pps);
idr.extend(write_idr_slice());
let p5 = planner.plan_au(&idr).unwrap();
assert!(p5.picture.references_clean, "an IDR is always clean");
let p6 = planner.plan_au(&write_p_slice(1, 2, 1, 1, None)).unwrap();
assert!(
p6.picture.references_clean,
"the damaged chain died with the IDR's DPB flush"
);
}
/// A stream that never loses a reference reports `references_clean` on every
/// picture, forever — the property that makes this free to carry in production.
#[test]
fn a_healthy_stream_reports_clean_references_on_every_picture() {
let (sps, pps) = authored_sps_pps();
let mut au0 = param_set_au(&sps, &pps);
au0.extend(write_idr_slice());
let mut planner = H264Planner::new();
assert!(planner.plan_au(&au0).unwrap().picture.references_clean);
// log2_max_frame_num_minus4 = 0 and pic_order_cnt_lsb is u(4): both wrap at 16.
for n in 1..16u32 {
let plan = planner
.plan_au(&write_p_slice(n, (n * 2) % 16, 1, 1, None))
.unwrap();
assert!(
picture_warnings(&plan).is_empty(),
"frame {n} should plan cleanly: {:?}",
picture_warnings(&plan)
);
assert!(plan.picture.references_clean, "frame {n} must read clean");
}
}
#[test]
fn a_gap_placeholder_inside_a_ref_list_is_substituted_in_place_not_compacted() {
let (sps, pps) = authored_sps_pps();
+104 -5
View File
@@ -166,6 +166,18 @@ pub struct PicturePlan {
/// came from the SPS by index) — Vulkan's `NumBitsForSTRefPicSetInSlice`.
pub short_term_ref_pic_set_size_bits: u32,
pub recovery_point: Option<RecoveryPointHevc>,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain — so a host claim that this AU is a clean re-anchor
/// (`USER_FLAG_RECOVERY_ANCHOR`) can be corroborated rather than taken on trust.
/// `true` for an IRAP (nothing to predict from) and for any picture whose whole
/// reference chain is clean; `false` from the moment this AU — or anything it
/// descends from — needed concealment.
///
/// Purely additive observation: nothing in the plan, the warnings or the DPB
/// changes because of it, and on a stream that never loses a reference it is
/// `true` on every picture forever. See [`crate::clean`] for why it propagates and
/// why every rule errs toward `false`.
pub references_clean: bool,
}
/// A reference list / RPS entry: the minimum every backend picparams format needs.
@@ -232,6 +244,28 @@ pub enum PlanWarning {
NonZeroReorder { max_num_reorder_pics: u8 },
}
impl PlanWarning {
/// Does this warning mean the PICTURE is damaged? The H.265 twin of
/// [`crate::h264::PlanWarning::is_integrity`] — the same one-list argument applies,
/// and `pf_vkdecode::is_integrity_warning_h265` delegates here.
///
/// `NonZeroReorder` is NOT damage, and excluding it matters more here than the
/// H.264 exclusions do: it fires on the AU that ACTIVATES an SPS — the opening
/// IRAP, and the fresh IRAP at every ABR resolution change — so treating it as
/// concealment would cost a released-unshown frame plus a keyframe round trip at
/// every renegotiation, on a stream the planner says it planned correctly. It
/// would also poison the [`crate::clean::CleanLedger`] at exactly those IRAPs,
/// marking the one picture that is clean by construction as broken.
///
/// Exhaustive with no wildcard, for the reason the H.264 twin spells out.
pub fn is_integrity(&self) -> bool {
match self {
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. } => true,
PlanWarning::NonZeroReorder { .. } => false,
}
}
}
/// The AU cannot be planned at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlanError {
@@ -452,6 +486,10 @@ pub struct H265Planner {
reported_live: BTreeSet<PicId>,
/// Set by [`Self::flush`]: planning resumes only at an IRAP (upstream: `Reset`).
awaiting_idr: bool,
/// Which resident pictures came off a BROKEN reference chain — the fact behind
/// [`PicturePlan::references_clean`]. Empty on a healthy stream; see
/// [`crate::clean::CleanLedger`] for the propagation rules.
clean: crate::clean::CleanLedger,
}
impl Default for H265Planner {
@@ -471,6 +509,7 @@ impl Default for H265Planner {
pending_outputs: Vec::new(),
reported_live: BTreeSet::new(),
awaiting_idr: false,
clean: Default::default(),
}
}
}
@@ -690,7 +729,20 @@ impl H265Planner {
let cur = current
.ok_or_else(|| PlanError::Parse("access unit contains no coded picture".into()))?;
let picture = Self::picture_plan(&cur, recovery_point);
// Was every picture this AU predicts from decoded off an intact chain? Asked
// over the SLICE reference lists rather than the RPS or the DPB snapshot,
// because those are what this picture actually predicts from: 8.3.2 RETAINS
// pictures in the RPS that the current picture does not use
// (`used_by_curr_pic` clear), and a damaged one among those says nothing about
// this picture. An IRAP's lists are empty, so this is vacuously true for it
// (`CleanLedger::references_clean`).
let references_clean = self.clean.references_clean(
slices
.iter()
.flat_map(|s: &SlicePlan| s.ref_list0.iter().chain(&s.ref_list1))
.map(|r| r.id),
);
let picture = Self::picture_plan(&cur, recovery_point, references_clean);
let rps = cur.rps_plan.clone();
let dpb_refs = cur.dpb_refs.clone();
// The activated parameter sets ride out with the plan (AuPlan field docs);
@@ -708,6 +760,20 @@ impl H265Planner {
let removed = previously_live.difference(&live_after).copied().collect();
self.reported_live = live_after;
// Fold this picture's verdict, then bound the ledger to DPB residency. Both
// AFTER `finish_picture`, so `stored` is the id the picture really got and
// `live_after` reflects the C.3.4/8.3.2 marking this AU performed — a mark
// written against a pre-marking view could survive an eviction it should have
// died with. `concealed` mirrors what a consumer conceals on, via the ONE
// classification (`PlanWarning::is_integrity`), so the ledger and the consumer
// can never disagree about whether this AU was damaged.
self.clean.note_stored(
stored,
references_clean,
warnings.iter().any(PlanWarning::is_integrity),
);
self.clean.retain_live(self.reported_live.iter().copied());
Ok(AuPlan {
picture,
rps,
@@ -745,6 +811,9 @@ impl H265Planner {
// re-entry sound.
self.first_picture_after_eos = true;
self.awaiting_idr = true;
// The DPB is drained, so no mark describes a resident picture any more — and
// planning resumes at an IRAP, which is clean by construction.
self.clean.clear();
DpbUpdate {
stored: None,
@@ -1452,6 +1521,7 @@ impl H265Planner {
fn picture_plan(
cur: &CurrentPicState,
recovery_point: Option<RecoveryPointHevc>,
references_clean: bool,
) -> PicturePlan {
let pic = &cur.pic;
// The first slice's PPS defines the picture's parameters; `cur.pps` may have
@@ -1498,6 +1568,7 @@ impl H265Planner {
max_dpb_frames: dpb_limit(sps),
short_term_ref_pic_set_size_bits: pic.short_term_ref_pic_set_size_bits,
recovery_point,
references_clean,
}
}
}
@@ -1550,11 +1621,15 @@ mod tests {
/// `NonZeroReorder` is excluded: the vendored conformance clips are general
/// (reordering) encodes, and the planner deliberately plans them while flagging
/// the envelope fact.
///
/// Delegates rather than restating the list. This harness exists to prove the
/// planner conceals exactly where production conceals, so a second copy here
/// could drift and quietly prove the wrong thing — and a `matches!` in
/// particular reads any FUTURE variant as clean, which is the one answer a
/// damage predicate must never default to. [`PlanWarning::is_integrity`] is an
/// exhaustive match, so a new variant stops the compiler there instead.
fn is_integrity_warning(w: &PlanWarning) -> bool {
matches!(
w,
PlanWarning::MissingReference { .. } | PlanWarning::TruncatedAu { .. }
)
w.is_integrity()
}
/// Plan a whole vendored clip and assert the global invariants: every AU plans,
@@ -1658,6 +1733,30 @@ mod tests {
assert!(!bbb.is_empty());
}
/// The false-positive guard for [`PicturePlan::references_clean`], on REAL
/// bitstreams rather than authored ones: two conformance clips that lose nothing
/// must report every single picture clean. A regression that let the ledger mark a
/// healthy stream would refuse every host recovery anchor and force an IDR on
/// every loss — the cheap re-anchor path gone, silently.
///
/// These clips carry B-slices and real reordering, so they also exercise the
/// "reference lists, not the RPS" reading: 8.3.2 retains pictures the current
/// picture does not use, and folding those in would condemn pictures at random.
#[test]
fn a_lossless_conformance_clip_reports_clean_references_on_every_picture() {
for (name, clip) in [("bear", TEST_BEAR), ("bbb", TEST_BBB)] {
let (_, plans) = plan_whole_clip(clip);
assert!(!plans.is_empty(), "{name} produced no plans");
for (i, plan) in plans.iter().enumerate() {
assert!(
plan.picture.references_clean,
"{name} picture {i} (poc {}) must read clean on a lossless clip",
plan.picture.pic_order_cnt
);
}
}
}
#[test]
fn b_slices_get_a_future_led_list1_distinct_from_list0() {
let aus = split_into_aus(TEST_64X64_I_P_B_P);
+1
View File
@@ -21,6 +21,7 @@
#![forbid(unsafe_code)]
pub mod av1;
pub mod clean;
pub mod h264;
pub mod h265;
pub mod sei;
+32 -3
View File
@@ -1093,9 +1093,38 @@ fn pump(
// `image.is_keyframe()` as the decoder's own IDR belt, applies the two-mark
// rule + the mark-patience backstop, clears the no-output streak, and returns
// whether to present this frame or withhold it as a post-loss concealment.
let present =
gate.on_decoded(frame.flags, image.is_keyframe(), Instant::now())
== GateVerdict::Present;
//
// CORROBORATED (the grey-frame fix): the wire's RECOVERY_ANCHOR is the host
// asserting something about THIS decoder — "the picture I coded this
// P-frame against is one you still hold, intact" — and it lifts the freeze
// on the FIRST occurrence, no two-mark wait. The host derives that from
// bookkeeping that tracks what the client RECEIVED, not what it managed to
// DECODE, and when those diverge the anchor lifts the freeze onto a
// concealed picture and LEAVES it lifted: grey with motion painted on it
// until some later signal re-arms and the 500 ms backstop extracts a real
// IDR. A rung that planned the AU itself knows better, so it says so here.
//
// What a refusal costs is exactly one thing: the freeze keeps holding the
// last good picture until the backstop fires on its ORIGINAL deadline and
// forces the IDR the anchor failed to be. That is strictly the better half
// of the trade — the alternative is presenting a picture this client can
// prove is damaged — and it is the same direction every rule in the gate
// errs in. Every non-native lane reports `Unavailable` and is untouched.
let evidence = image.anchor_evidence();
if evidence == punktfunk_core::reanchor::AnchorEvidence::ReferencesDamaged
&& frame.flags & punktfunk_core::packet::USER_FLAG_RECOVERY_ANCHOR != 0
{
tracing::debug!(
"refused a host recovery anchor: this AU predicts from a picture \
this decoder had to conceal holding for a real IDR"
);
}
let present = gate.on_decoded_corroborated(
frame.flags,
image.is_keyframe(),
evidence,
Instant::now(),
) == GateVerdict::Present;
total_frames += 1;
// ⚠ The `stats:` decode-path tag is a machine interface —
// additive only. M10 removed the rungs whose tags were `vaapi`,
+45
View File
@@ -493,6 +493,17 @@ pub struct NativeVkFrame {
/// the host, and it cannot be lost separately from the picture. Fed to
/// [`ReanchorGate::on_local_recovery`](punktfunk_core::reanchor::ReanchorGate::on_local_recovery).
pub recovery: punktfunk_core::reanchor::LocalRecovery,
/// Every picture this AU predicts from was itself decoded from a fully-available
/// reference chain (pf-vkdecode's `DecodedVkFrame::references_clean`).
///
/// The corroboration for the host's `USER_FLAG_RECOVERY_ANCHOR`, which is a claim
/// about THIS decoder that only this decoder can check. The host derives its
/// anchor from slot bookkeeping that tracks what the client RECEIVED; this tracks
/// what the client managed to DECODE. When they disagree the anchor lifts the
/// post-loss freeze onto a concealed picture and leaves it lifted, which is the
/// grey-with-motion field report. `true` on every ordinary frame of a healthy
/// stream, so the flag is only ever load-bearing on the AU that carries an anchor.
pub references_clean: bool,
/// This picture's position in DECODE order (pf-vkdecode's strictly increasing
/// per-session ordinal). Delivery order is not decode order: after a failed AU
/// the H.265 decoder flushes its DPB, handing back every buffered picture at
@@ -559,6 +570,40 @@ impl DecodedImage {
}
}
/// What this lane can say about the host's re-anchor claim on this frame — the
/// corroboration for `USER_FLAG_RECOVERY_ANCHOR`, fed to
/// [`ReanchorGate::on_decoded_corroborated`](punktfunk_core::reanchor::ReanchorGate::on_decoded_corroborated).
///
/// An anchor is the host asserting a fact about THIS decoder — *the picture I
/// coded this P-frame against is one you still hold, intact* — and the gate lifts
/// its post-loss freeze on the first one, no two-mark wait. Only a rung that
/// planned the AU itself knows which pictures it predicts from and whether each of
/// those decoded cleanly, so only such a rung can catch the host being wrong.
///
/// The native Vulkan rung answers; everyone else reports
/// [`AnchorEvidence::Unavailable`](punktfunk_core::reanchor::AnchorEvidence::Unavailable)
/// and the gate treats them exactly as it did before this existed — silence is not
/// refutation, so no lane becomes stricter by accident.
///
/// ⚠ The CPU rung's H.264 leg plans every AU with the same `H264Planner` and so
/// COULD answer; it does not yet, because its frame type carries no equivalent of
/// [`NativeVkFrame::references_clean`]. Reporting `Unavailable` there is the
/// conservative reading (today's behaviour), not a claim that its references are
/// fine.
pub fn anchor_evidence(&self) -> punktfunk_core::reanchor::AnchorEvidence {
use punktfunk_core::reanchor::AnchorEvidence;
match self {
DecodedImage::NativeVk(f) => {
if f.references_clean {
AnchorEvidence::ReferencesClean
} else {
AnchorEvidence::ReferencesDamaged
}
}
_ => AnchorEvidence::Unavailable,
}
}
/// This frame's position in DECODE order, where the lane knows one — see
/// [`NativeVkFrame::decode_order`]. `None` everywhere else, which is what the
/// pump reads as "this lane reports no local recovery either, so there is
@@ -3060,6 +3060,11 @@ mod tests {
picture: pf_vaadec::PicturePlanAv1 {
frame_type: pf_vaadec::FrameTypeAv1::KeyFrame,
is_key: true,
// Vacuously true for a key frame: it predicts from nothing. This fixture
// exists to exercise the SIZING path (sequence max vs coded vs render), so
// the clean bit is incidental here — but it must state the honest value,
// because `false` is the answer that withholds a re-anchor.
references_clean: true,
show_frame: true,
showable_frame: false,
order_hint: 0,
@@ -741,6 +741,12 @@ fn project_frame(frame: &DecodedVkFrame, guard: NativeReleaseGuard) -> NativeVkF
sei_here: frame.recovery.sei_here,
is_recovery_point: frame.recovery.is_recovery_point,
},
// Whether this picture's own references decoded cleanly — the corroboration
// the shared gate weighs a host `USER_FLAG_RECOVERY_ANCHOR` against. The
// planner already knows it (it is the one party that resolved this AU's
// reference lists), and it is the only thing that can catch the host
// asserting a re-anchor over a picture THIS decoder had to conceal.
references_clean: frame.references_clean,
// Which side of a loss this picture was DECODED on. Carried beside the
// recovery mark because the mark is worthless without it: a post-failure
// DPB flush delivers pre-loss pictures after the loss, and their marks
@@ -1690,6 +1696,11 @@ mod tests {
sei_here: true,
is_recovery_point: true,
},
// SET, per this fixture's no-boolean-is-false rule — and it earns the
// rule: a projection that dropped this would default it to `false`,
// which reads as "this picture's references were concealed" and would
// make the gate refuse EVERY host recovery anchor on a healthy stream.
references_clean: true,
// Distinct from every other number here for the same reason: a
// projection that dropped the decode ordinal would make every frame
// look pre-loss (0) and silently disable the local-recovery path.
@@ -1792,6 +1803,7 @@ mod tests {
keyframe,
poc,
recovery,
references_clean,
decode_order,
guard: _,
} = p;
@@ -1838,6 +1850,12 @@ mod tests {
"the recovery point SEI's verdict reaches the gate — it is the ONLY \
clean point an intra-refresh session has"
);
assert!(
references_clean,
"the reference-cleanliness verdict rides along — without it the gate \
cannot refute a host recovery anchor that names a picture this decoder \
had to conceal, which is the grey-with-motion field report"
);
assert_eq!(
decode_order, 17,
"the decode ordinal rides along — without it the pump cannot tell a \
@@ -2150,6 +2168,7 @@ mod tests {
keyframe: true,
poc: 0,
recovery: punktfunk_core::reanchor::LocalRecovery::NONE,
references_clean: true,
decode_order: 1,
guard: NativeReleaseGuard::new(
tx,
+26
View File
@@ -368,6 +368,32 @@ pub trait Encoder: Send {
fn invalidate_ref_frames(&mut self, _first_frame: i64, _last_frame: i64) -> bool {
false
}
/// Mark every resident reference UNTRUSTED FOR RFI ANCHORING — the answer to "the client told
/// us it has damage and we did NOT repair it".
///
/// Why this exists at all. The slot-family RFI trust domain is the WIRE index each reference
/// holds, which answers *did the client receive this frame*; what an anchor pick actually needs
/// is *did the client DECODE it intact*. [`super::rfi`]'s taint sweep bridges that gap, but it
/// only runs inside [`invalidate_ref_frames`] — reachable from exactly ONE of the client's five
/// damage signals (the frame-index gap, which carries a loss RANGE). The other four report
/// through [`request_keyframe`](Self::request_keyframe), which carries no range and so cannot
/// sweep anything. That is self-healing while the IDR is actually emitted — an IDR flushes the
/// DPB and rebuilds trust from scratch — but the host coalesces those requests (a keyframe
/// storm is a 20-40× spike that deepens the very loss it recovers), and a coalesced request
/// leaves the client's damage unrepaired AND unrecorded. Those references stay anchor
/// candidates, and the next loss is answered with one of them tagged `recovery_anchor` — the
/// client's *definitive* clean re-anchor signal, which lifts its post-loss freeze on the first
/// occurrence. Grey frames, presented, with the freeze lifted.
///
/// Distrust is deliberately NOT "unusable": ordinary prediction runs off the backend's own slot
/// INDEX, never the wire domain, so this costs nothing but the next anchor pick — which
/// declines and falls through to the (still coalesced, so still non-storming) keyframe path.
/// It is also self-correcting on all three backends: a slot re-marked with a fresh frame, or an
/// IDR flushing the DPB, restores trust within a few frames. So this can suppress RFI briefly,
/// never permanently.
///
/// Default: no-op — the backends with no reference bookkeeping have no trust to withdraw.
fn distrust_references(&mut self) {}
/// Escalate into a pipelined (two-thread) retrieve mode under sustained GPU contention — the
/// encoder analog of the capturer depth escalation: AUs ride ~one loop tick behind (`poll`
/// may return `None` while an encode is in flight) in exchange for capture/submit no longer
@@ -3991,6 +3991,35 @@ impl Encoder for VulkanVideoEncoder {
}
}
/// Withdraw anchor trust from every resident reference (trait docs carry the why).
///
/// The mechanism is this backend's half of the split `enc::rfi` documents: blank `slot_wire`
/// ONLY. `slot_poc` MUST keep naming every physically-resident DPB picture — it is what
/// [`build_h265_rps_s0`] retains the RPS from, and an RPS that stops naming a resident lets a
/// conforming decoder mark it "unused for reference" and reclaim it (8.3.2), so a later anchor
/// would reference a picture the client has already dropped. That is its own grey-screen bug,
/// documented on `build_h265_rps_s0`, and it is the exact failure this method exists to
/// prevent — so getting the two domains the wrong way round here would trade one for the other.
/// `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain.
///
/// `pending_loss` is deliberately left armed, matching this backend's decline arm: a stale arm
/// is re-resolved at frame-build, where the re-pick now finds nothing trusted and forces the
/// IDR that heals the stream. Clearing it here would ship an untagged plain P instead.
///
/// Ordinary prediction is untouched — it runs off `prev_slot`, an index, not a wire.
fn distrust_references(&mut self) {
let trusted = self.slot_wire.iter().filter(|&&w| w >= 0).count();
if trusted == 0 {
return; // already fully distrusted — nothing to log or clear
}
self.slot_wire.iter_mut().for_each(|w| *w = -1);
tracing::debug!(
trusted,
"vulkan-encode: client reported unrepaired damage — withdrawing RFI anchor trust from \
every resident reference (prediction and the RPS are unaffected)"
);
}
fn poll(&mut self) -> Result<Option<EncodedFrame>> {
// Backpressure-drained frames (already read, oldest) come out first, then the oldest slot
// still in flight — both in submission order. BLOCKING, per the depth-1 pump contract
+52
View File
@@ -175,4 +175,56 @@ mod tests {
apply(&mut all, plan.tainted);
assert_eq!(pick_anchor(&view(&all), 5), None);
}
/// `Encoder::distrust_references` — the OTHER way trust is withdrawn, and the one that needs no
/// loss range. The host calls it when the client reports damage the host did not repair (a
/// coalesced keyframe request, or an RFI anchor the client kept asking past): the sweep cannot
/// run there because a keyframe request carries no range, so every resident reference is
/// withdrawn wholesale instead. All three backends persist that through their own marker; what
/// the shared policy must guarantee is the consequence — the next pick finds nothing and
/// declines, so the caller keyframes instead of serving an anchor over unrepaired damage.
#[test]
fn distrusting_every_reference_makes_the_next_anchor_pick_decline() {
// A table with plenty of pre-loss candidates: without the withdrawal, wire 7 anchors.
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
assert_eq!(
pick_anchor(&view(&wires), 9),
Some((3, 7)),
"precondition: this table would happily anchor"
);
// The Vulkan mechanism (blank the wire) stands in for all three: AMF clears its mirror and
// QSV raises `ltr_tainted`, but each is filtered out of the trusted view identically —
// which is exactly what makes one pure policy serve three persistence schemes.
apply(&mut wires, u32::MAX);
assert_eq!(
pick_anchor(&view(&wires), 9),
None,
"every reference withdrawn → no anchor, caller falls through to its keyframe path"
);
// And it holds for ANY later loss, not just this one — the point of persisting distrust.
assert_eq!(pick_anchor(&view(&wires), 100), None);
}
/// The withdrawal must be temporary, or one coalesced keyframe request would cost a session its
/// RFI recovery for good and every later loss would ride the 20-40× IDR path. Each backend
/// restores trust the same way it always did — a slot re-marked with a fresh frame (and an IDR
/// flush, which empties the table first) — so a refilled slot anchors again.
#[test]
fn a_re_marked_slot_restores_anchor_trust_after_a_full_withdrawal() {
let mut wires = [4i64, 5, 6, 7, -1, -1, -1, -1];
apply(&mut wires, u32::MAX);
assert_eq!(pick_anchor(&view(&wires), 20), None);
// Encoding continues; the ring refills two slots with post-withdrawal frames. Those really
// are clean — the client's damage was repaired by the IDR the withdrawal forced — so they
// are legitimate anchors and the sweep must not keep rejecting them.
wires[0] = 14;
wires[1] = 15;
assert_eq!(
pick_anchor(&view(&wires), 20),
Some((1, 15)),
"a re-marked slot is trusted again — the suppression is a few frames, not the session"
);
}
}
+24
View File
@@ -2010,6 +2010,30 @@ impl Encoder for AmfEncoder {
}
}
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
///
/// This backend's mechanism, unchanged from the sweep's: distrust = clear the mirror slot.
/// Dropped slots stay dropped and the marking cadence re-marks a clean frame within ~1/4 s, so
/// the suppression is brief by construction.
///
/// `pending_force` is cleared with them, matching the decline arm above: an un-consumed force
/// would otherwise point at a slot this call just distrusted, and the next submit would
/// force-reference it anyway — shipping the corruption tagged `recovery_anchor`, which is the
/// whole failure being closed.
fn distrust_references(&mut self) {
let live = self.ltr_slots.iter().filter(|m| m.is_some()).count();
if live == 0 && self.pending_force.is_none() {
return;
}
self.ltr_slots = [None; NUM_LTR_SLOTS];
self.pending_force = None;
tracing::debug!(
live,
"AMF LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
live LTR (the marking cadence re-marks a clean frame within ~1/4 s)"
);
}
fn caps(&self) -> EncoderCaps {
EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
+33
View File
@@ -1467,6 +1467,39 @@ impl Encoder for QsvEncoder {
}
}
/// Withdraw anchor trust from every live LTR (trait docs carry the why).
///
/// This backend's mechanism, unchanged from the sweep's: distrust is the SEPARATE
/// `ltr_tainted` flag, never a cleared mirror slot. `ltr_slots` mirrors the HARDWARE DPB and
/// nulling an entry issues no VPL call, so the frame stays marked long-term in the encoder —
/// and the RejectedRefList built at submit only names `Some` slots, so a cleared mirror would
/// silently SKIP the very entry being distrusted and the recovery frame could still predict
/// from it. Taint keeps the mirror intact and the rejection reachable.
///
/// The taint lifts itself: an IDR flush and a re-mark both clear it, so this suppresses RFI
/// for a few frames, never for the session.
///
/// `pending_force` is cleared for the same reason as the decline arm above — an un-consumed
/// force would point at a slot this call just distrusted.
fn distrust_references(&mut self) {
let live = self
.ltr_slots
.iter()
.enumerate()
.filter(|&(slot, m)| m.is_some() && !self.ltr_tainted[slot])
.count();
if live == 0 && self.pending_force.is_none() {
return;
}
self.ltr_tainted = [true; NUM_LTR_SLOTS];
self.pending_force = None;
tracing::debug!(
live,
"QSV LTR-RFI: client reported unrepaired damage — withdrawing anchor trust from every \
live LTR (cleared by the next re-mark or IDR flush)"
);
}
fn caps(&self) -> EncoderCaps {
EncoderCaps {
// As Windows NVENC: the capturer composites; this backend never reads `frame.cursor`.
+8
View File
@@ -274,6 +274,14 @@ impl Encoder for TrackedEncoder {
fn invalidate_ref_frames(&mut self, first_frame: i64, last_frame: i64) -> bool {
self.inner.invalidate_ref_frames(first_frame, last_frame)
}
// Same trap class as `set_wire_chunking`, and the one where it would hurt most: unforwarded,
// the default no-op would leave every session serving RFI anchors over damage the client
// reported and the host never repaired — the failure this method exists to close, silently
// reintroduced by the wrapper. (The `every_encoder_method_is_forwarded` guard below catches
// it, which is exactly why that guard is there.)
fn distrust_references(&mut self) {
self.inner.distrust_references()
}
// Forwarded for the same reason as `set_wire_chunking` below — the unforwarded default
// (`false` = "backend can't pipeline, stop asking") silently killed the §7 LN3 contention
// escalation for every session, since the host loop only ever holds the wrapped box.
+240 -46
View File
@@ -185,6 +185,24 @@ pub struct DecodedVkFrame {
/// the loss, and lift a freeze on a wave that completed before it. Comparing
/// this ordinal against the one current at the arm is what tells them apart.
pub decode_order: u64,
/// Every picture this one was predicted from came off a fully-available reference
/// chain ([`pf_bitstream::h264::PicturePlan::references_clean`] and its two twins).
/// `true` for an IDR/IRAP/key frame and for any picture whose whole chain is clean;
/// `false` from the moment this AU — or anything it descends from — needed
/// concealment.
///
/// It exists to let a consumer CORROBORATE a host's claim that a frame is a clean
/// re-anchor. `USER_FLAG_RECOVERY_ANCHOR` — the host's LTR-RFI recovery frame —
/// lifts a post-loss freeze on its FIRST occurrence, exactly like a real IDR,
/// because the host says the frame was coded against a known-good reference. The
/// host's "known-good" is an inference from what the client RECEIVED; this is what
/// the client actually DECODED. Where they disagree the freeze used to lift onto a
/// gray plate and every frame after it chained off the corruption, with nothing
/// left to re-arm the gate.
///
/// A consumer with no such claim to check can ignore it: it is an observation
/// about the stream, and nothing in this crate's own behaviour reads it.
pub references_clean: bool,
/// The decode op's slot in the status query pool.
pub query_slot: u32,
/// The decode op's submission ordinal (validates the query slot has not been
@@ -281,12 +299,24 @@ pub enum VkDecodeError {
/// correct consumer can never hit this). The AU was planned but NOT decoded;
/// release frames and request a keyframe.
NoFreeSlot,
/// A DPB slot this AU references holds no bound image. H.265 only, and fatal
/// rather than skippable: `StdVideoDecodeH265PictureInfo`'s RPS arrays are
/// INDICES into `pReferenceSlots`, so dropping one entry would silently
/// re-point every later index at the wrong picture — the exact class of
/// plausible-looking corruption this crate refuses to produce. (H.264 carries
/// no such index arrays and only traces the case.)
/// A DPB slot this AU references holds no bound image. Fatal on all three codecs
/// rather than skippable.
///
/// H.265 and AV1 have a structural argument: their picture info names DPB slots by
/// INDEX (the H.265 RPS arrays, AV1's name-indexed `refs`), so dropping one entry
/// silently re-points a later index at the wrong picture. H.264 has no such index
/// arrays, and used to skip the case with a `trace!` on exactly that reasoning —
/// but the reasoning was about the STRUCTURE, not the output. The hardware still
/// decodes a P-picture against a reference that was never bound, and on the
/// DPB-and-output-COINCIDE path that is a gray plate with the new frame's motion
/// painted over it. Nothing warned: the planner's DPB genuinely holds the picture
/// (the breakage is this crate's slot→image ledger), so the frame was shipped,
/// presented, and cleared the consumer's demotion streak on the way past —
/// invisible damage, which is the one outcome this crate exists to make impossible.
///
/// Failing closed is only safe because it is paired with recovery: the latch
/// ([`crate::decoder_h265::RecoveryLatch`]) flushes to the next IRAP/IDR rather
/// than leaving the stream wedged on a slot nothing can honour.
UnboundReferenceSlot { slot: u8 },
/// The frame belongs to a generation whose retired pool is already gone
/// (double release, or a frame outliving its graveyard entry).
@@ -615,6 +645,11 @@ pub(crate) struct PendingPic {
pub(crate) recovery: crate::recovery::RecoveryMark,
/// See [`DecodedVkFrame::decode_order`].
pub(crate) decode_order: u64,
/// Read off the plan at DECODE time and carried here for the same reason
/// [`Self::recovery`] is: display order is not decode order, and this describes
/// the picture rather than the moment it is delivered. See
/// [`DecodedVkFrame::references_clean`].
pub(crate) references_clean: bool,
}
/// A retired generation's picture pool: images the presenter still holds live
@@ -678,7 +713,15 @@ pub struct VkH264Decoder {
/// The outstanding recovery point SEI, if any — see [`crate::recovery`].
/// Survives session rebuilds on purpose: it is a fact about the STREAM's
/// prediction structure, not about this decoder's Vulkan objects.
///
/// Named apart from [`Self::recovery`], which is this decoder's DPB-recovery
/// latch: the two are unrelated (one is a fact about the stream's prediction
/// structure, the other about this decoder's own wedged state).
recovery_watch: crate::recovery::RecoveryWatch,
/// Post-failure DPB recovery owed — see
/// [`crate::decoder_h265::RecoveryLatch`], whose docs carry the whole
/// fail-closed/recover argument for all three codecs.
recovery: crate::decoder_h265::RecoveryLatch,
/// Pictures planned so far — stamped onto each one as
/// [`DecodedVkFrame::decode_order`]. Survives session rebuilds for the same
/// reason the watch does.
@@ -724,6 +767,7 @@ impl VkH264Decoder {
graveyard: Vec::new(),
last_warnings: Vec::new(),
recovery_watch: crate::recovery::RecoveryWatch::new(),
recovery: Default::default(),
decoded: 0,
generation: 0,
device_lost: false,
@@ -748,6 +792,12 @@ impl VkH264Decoder {
}
fn decode_inner(&mut self, au: &[u8]) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
// A previous AU failed after its planning had advanced: clear the stale
// DPB residency BEFORE planning this one, or every AU referencing the
// stranded picture fails forever ([`RecoveryLatch`] docs).
if self.recovery.take() {
self.recover_dpb();
}
// `take_warnings` promises "cleared by the next decode", and this IS a
// decode: clear BEFORE planning, so an AU that fails to plan at all cannot
// leave the previous AU's warnings behind to be re-read as damage on the
@@ -784,7 +834,38 @@ impl VkH264Decoder {
);
}
self.ensure_state(&plan)?;
// From here the PLANNER has already advanced past this AU — its DPB holds
// the picture whatever happens next — so any failure below leaves the
// planner's DPB and this decoder's slot/image ledgers able to disagree.
// Latch the recovery for the next decode rather than returning into a
// permanently wedged state. (Deliberately wider than the paths that mutate
// the SlotMap: a failure BEFORE `plan_to_vk` mutates it — an `ensure_state`
// refusal, a `NoFreeSlot` — strands the picture the other way round,
// planner-resident with no slot at all, and wedges just as hard. One flush
// cures both.) The H.265 twin, for the same reason: `decoder_h265`'s
// `RecoveryLatch` docs carry the whole argument.
let result = self.decode_planned(&plan, au, recovery, decode_order);
if result.is_err() {
self.recovery.latch();
}
result
}
/// The submission half of one decode, from the point the planner has already
/// advanced. Split out so [`Self::decode_inner`] can latch recovery on ANY
/// failure past that line without threading a flag through every exit.
/// `au` is the same buffer `plan`'s slice ranges index into; `recovery` is the
/// recovery-point verdict already folded for this AU and `decode_order` its
/// decode-order ordinal (both advance in decode order, so neither can be
/// derived here — this path is not reached for every planned AU).
fn decode_planned(
&mut self,
plan: &AuPlan,
au: &[u8],
recovery: crate::recovery::RecoveryMark,
decode_order: u64,
) -> Result<Option<DecodedVkFrame>, VkDecodeError> {
self.ensure_state(plan)?;
let sps_id = plan.sps.seq_parameter_set_id;
// Convert, with ONE rebuild retry on CapacityMismatch — the designed
@@ -810,7 +891,7 @@ impl VkH264Decoder {
// satisfies ensure_parameters' Recreate contract, and Current/Add
// touch nothing a submitted decode reads.
unsafe { state.session.ensure_parameters(&plan.sps, &plan.pps)? };
match plan_to_vk(&plan, &mut state.slots, sps_id) {
match plan_to_vk(plan, &mut state.slots, sps_id) {
Ok(converted) => {
vk_plan = Some(converted);
break;
@@ -820,7 +901,7 @@ impl VkH264Decoder {
required,
capacity, "DPB depth renegotiated — rebuilding session"
);
self.rebuild_state(&plan)?;
self.rebuild_state(plan)?;
}
Err(e) => return Err(VkDecodeError::Convert(e)),
}
@@ -1002,6 +1083,7 @@ impl VkH264Decoder {
is_idr: plan.picture.is_idr,
recovery,
decode_order,
references_clean: plan.picture.references_clean,
},
);
Ok(())
@@ -1358,6 +1440,40 @@ impl VkH264Decoder {
}
}
/// Clear the DPB state a failed AU left behind, so planning resumes at the
/// next IDR instead of erroring on residency nothing can honour.
///
/// Three ledgers have to agree and, after a post-planning failure, do not:
/// the PLANNER's DPB, this decoder's [`SlotMap`], and the slot→image
/// bindings. [`Self::flush`] settles the first (and hands back any picture
/// that did reach output — those frames are real and are still delivered),
/// then [`crate::decoder_h265::reset_slot_bindings`] empties the other two.
/// Pool images the stale bindings pinned go back on the free list; images a
/// consumer still HOLDS stay pinned by their own `held` counts, exactly as
/// they would across a session rebuild.
///
/// Deliberately not a session rebuild: the session, pools and ring are all
/// still valid — only the DPB bookkeeping is stale — and a rebuild would
/// churn every image allocation for a condition an IDR fixes anyway.
///
/// The H.265 twin (`decoder_h265::recover_dpb`) is the same function one codec
/// over; the two share `reset_slot_bindings` rather than the whole body because
/// each has to call its OWN `flush`, which settles its own planner's DPB.
fn recover_dpb(&mut self) {
debug!("recovering from a failed AU — flushing the H.264 DPB to the next IDR");
self.flush();
if let Some(state) = &mut self.state {
let unbound = crate::decoder_h265::reset_slot_bindings(
&mut state.slots,
&mut state.slot_image,
&mut state.slot_refs,
);
for picture in unbound {
state.pool.pictures[picture].bound = false;
}
}
}
/// Session/caps for THIS plan exist and match its extent + profile, and the
/// stream sits inside the device's level ceiling. DPB-depth mismatches
/// surface later as `plan_to_vk`'s `CapacityMismatch` (the designed trigger)
@@ -1657,6 +1773,7 @@ pub(crate) fn build_frame(
is_idr: entry.is_idr,
recovery: entry.recovery,
decode_order: entry.decode_order,
references_clean: entry.references_clean,
query_slot: entry.query_slot,
submission: entry.submission,
picture: entry.image as u32,
@@ -1848,37 +1965,9 @@ unsafe fn record_and_submit(
}
// ---- bound-slot staging ----
// Scope list: this AU's references first, then every other still-held slot
// (their resources must stay bound for their associations to persist), then
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
// without a current association; the decode op's setup slot then claims it).
let mut scope: Vec<(i32, vk::ImageView, hh::StdVideoDecodeH264ReferenceInfo)> = Vec::new();
for r in &vk_plan.refs {
match slot_view(state, r.slot) {
Some(view) => scope.push((i32::from(r.slot), view, r.std)),
None => trace!(slot = r.slot, "referenced slot without a bound image"),
}
}
for (slot, _id) in state.slots.held() {
if slot == vk_plan.setup_slot
|| scope
.iter()
.any(|&(index, _, _)| index >= 0 && index as u8 == slot)
{
continue;
}
match (state.slot_refs[usize::from(slot)], slot_view(state, slot)) {
(Some(std), Some(view)) => scope.push((i32::from(slot), view, std)),
// Unreachable in practice: every held slot was a setup slot once.
_ => trace!(
slot,
"held slot without reference info/binding — left unbound"
),
}
}
let reference_count = vk_plan.refs.len().min(scope.len());
// The setup/dst resource: the fresh pool image (coincide) or the DPB layer
// (distinct — the pool image is the separate decode output).
// (distinct — the pool image is the separate decode output). Resolved before the
// scope is built, because it is the scope's last entry.
let setup_view = if coincide {
state.pool.pictures[dst].view
} else {
@@ -1888,31 +1977,48 @@ unsafe fn record_and_submit(
.expect("distinct mode")
.dpb_view(vk_plan.setup_slot)
};
scope.push((-1, setup_view, vk_plan.setup_ref));
// Scope list: this AU's references first, then every other still-held slot
// (their resources must stay bound for their associations to persist), then
// the setup slot as the ACTIVATION entry (slot index -1 binds its resource
// without a current association; the decode op's setup slot then claims it).
//
// Shared with H.265 (`decoder_h265::build_scope`): the two codecs' layout,
// fail-closed rule and reference-count derivation are the same algorithm over a
// different `StdVideo*` type, and this function's whole job is refusing to guess —
// the property least tolerant of two copies drifting apart.
let held: Vec<u8> = state.slots.held().map(|(slot, _id)| slot).collect();
let (scope, reference_count) = crate::decoder_h265::build_scope(
&vk_plan.refs,
held.into_iter(),
vk_plan.setup_slot,
setup_view,
vk_plan.setup_ref,
&state.slot_refs,
|slot| slot_view(state, slot),
)?;
// Staged arrays: resources → std infos → codec slot infos → slot infos. Each
// vector is fully built before the next borrows it, so nothing reallocates
// under a stored pointer.
let resources: Vec<vk::VideoPictureResourceInfoKHR<'_>> = scope
.iter()
.map(|&(_, view, _)| {
.map(|e| {
vk::VideoPictureResourceInfoKHR::default()
.coded_extent(coded_extent)
.base_array_layer(0)
.image_view_binding(view)
.image_view_binding(e.view)
})
.collect();
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> =
scope.iter().map(|&(_, _, std)| std).collect();
let std_refs: Vec<hh::StdVideoDecodeH264ReferenceInfo> = scope.iter().map(|e| e.std).collect();
let mut dpb_infos: Vec<vk::VideoDecodeH264DpbSlotInfoKHR<'_>> = std_refs
.iter()
.map(|std| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(std))
.collect();
let mut begin_slots: Vec<vk::VideoReferenceSlotInfoKHR<'_>> = Vec::with_capacity(scope.len());
for (index, &(slot_index, _, _)) in scope.iter().enumerate() {
for (index, entry) in scope.iter().enumerate() {
begin_slots.push(
vk::VideoReferenceSlotInfoKHR::default()
.slot_index(slot_index)
.slot_index(entry.slot_index)
.picture_resource(&resources[index]),
);
}
@@ -2038,8 +2144,96 @@ unsafe fn record_and_submit(
#[cfg(test)]
mod tests {
use ash::vk::Handle as _;
use super::*;
/// A fake, never-dereferenced view handle keyed by slot, so a scope's bindings
/// can be checked without a device (the H.265 tests' idiom, one codec over).
fn fake_view(slot: u8) -> vk::ImageView {
vk::ImageView::from_raw(u64::from(slot) + 1)
}
/// A reference-info value carrying just the field the assertions read.
fn h264_std_ref(frame_num: u16) -> hh::StdVideoDecodeH264ReferenceInfo {
// SAFETY: StdVideoDecodeH264ReferenceInfo is a plain-C bindgen struct of a
// bitfield word and integers; all-zero is valid for every field.
let mut std: hh::StdVideoDecodeH264ReferenceInfo = unsafe { std::mem::zeroed() };
std.FrameNum = frame_num;
std
}
fn h264_ref(slot: u8, frame_num: u16) -> crate::pic::VkRef {
crate::pic::VkRef {
slot,
std: h264_std_ref(frame_num),
id: u64::from(slot),
}
}
/// The H.264 leg of the fail-closed rule. It used to trace-and-continue here, on
/// the grounds that H.264 carries no RPS index arrays — but the hardware still
/// decoded the picture against a reference that was never bound, which is a gray
/// plate with motion on it, shipped with no warning attached. Fail closed.
#[test]
fn an_h264_reference_slot_without_a_bound_image_fails_the_whole_op() {
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
let slot_refs = vec![Some(h264_std_ref(0)); 8];
let err = crate::decoder_h265::build_scope(
&refs,
[1u8, 3].into_iter(),
0,
fake_view(0),
h264_std_ref(30),
&slot_refs,
|slot| (slot != 3).then(|| fake_view(slot)),
)
.unwrap_err();
assert!(
matches!(err, VkDecodeError::UnboundReferenceSlot { slot: 3 }),
"{err}"
);
}
/// `reference_count` must be the number of THIS AU's references and nothing else.
/// The old H.264 form (`refs.len().min(scope.len())`) was taken AFTER the
/// held-slot pass appended to the same vector, so a short refs list let the decode
/// op's reference array run past the references into unrelated held slots — a
/// picture predicted from something the stream never named.
#[test]
fn the_h264_reference_count_covers_the_references_and_never_a_held_slot() {
// Two references (slots 1, 3); slots 5 and 6 are held but NOT referenced.
let refs = vec![h264_ref(1, 10), h264_ref(3, 20)];
let slot_refs = vec![Some(h264_std_ref(77)); 8];
let (scope, reference_count) = crate::decoder_h265::build_scope(
&refs,
[1u8, 3, 5, 6].into_iter(),
0,
fake_view(0),
h264_std_ref(30),
&slot_refs,
|slot| Some(fake_view(slot)),
)
.unwrap();
assert_eq!(reference_count, 2, "exactly this AU's references");
assert_eq!(
scope[..reference_count]
.iter()
.map(|e| e.slot_index)
.collect::<Vec<_>>(),
vec![1, 3],
"the decode op's reference prefix is the references, in order"
);
// The rest of the scope keeps the other slots bound (so their associations
// survive) and ends on the setup activation entry — but none of that is a
// reference of this AU.
assert_eq!(
scope.iter().map(|e| e.slot_index).collect::<Vec<_>>(),
vec![1, 3, 5, 6, -1]
);
}
#[test]
fn settle_dpb_readies_outputs_in_order_and_returns_never_output_removals() {
let mut pending: BTreeMap<PicId, u32> = BTreeMap::new();
+1
View File
@@ -1114,6 +1114,7 @@ impl VkAv1Decoder {
is_idr: plan.picture.is_key,
recovery: crate::recovery::RecoveryMark::NONE,
decode_order,
references_clean: plan.picture.references_clean,
},
);
+104 -28
View File
@@ -139,13 +139,20 @@ struct SessionStateH265 {
///
/// This decoder FAILS CLOSED, and that stays: when an AU cannot be carried
/// through to a submitted decode, it returns an error rather than substituting a
/// reference or decoding against a slot whose image is gone. H.264's
/// soft-degrade (trace the missing binding, drop that reference, decode anyway)
/// is not available here because `StdVideoDecodeH265PictureInfo`'s
/// reference or decoding against a slot whose image is gone. The structural argument
/// is that `StdVideoDecodeH265PictureInfo`'s
/// `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` arrays hold INDICES into the
/// decode op's reference array — dropping one entry re-points every later index
/// at the wrong picture, which is the corruption-hiding class this crate refuses.
///
/// ⚠ H.264 used to soft-degrade here (trace the missing binding, drop that reference,
/// decode anyway) on the grounds that it carries no such index arrays. It now fails
/// closed and carries this same latch: the arrays were never the point, the OUTPUT
/// was. A P-picture decoded against a reference that was never bound is a gray plate
/// with motion painted over it, and because the planner raises no warning for it, that
/// frame reached the screen and cleared the consumer's demotion streak. Both codecs
/// now fail closed, and both recover through this latch rather than wedging.
///
/// But failing closed once must not wedge the stream FOREVER, and without this
/// latch it did: by the time an AU reaches a failure exit, `plan_to_vk_h265` has
/// already mutated the [`SlotMap`] (releases + the setup assignment) and the
@@ -636,6 +643,7 @@ impl VkH265Decoder {
is_idr: plan.picture.is_idr,
recovery,
decode_order,
references_clean: plan.picture.references_clean,
},
);
@@ -1247,10 +1255,15 @@ fn profile_key_for(plan: &AuPlan) -> Result<H265ProfileKey, VkDecodeError> {
/// let [`build_scope`] bind a slot the planner no longer knows about, which is the
/// same "plausible-looking picture in the wrong place" the unbound-reference
/// refusal exists to prevent.
fn reset_slot_bindings(
///
/// Generic over the cached reference-info type so H.264's recovery uses this exact
/// code rather than a twin: the three ledgers and the "empty them together" rule are
/// codec-independent (`SlotMap` is already shared), and only the `StdVideo*` type in
/// `slot_refs` differs.
pub(crate) fn reset_slot_bindings<S>(
slots: &mut SlotMap,
slot_image: &mut [Option<usize>],
slot_refs: &mut [Option<hh::StdVideoDecodeH265ReferenceInfo>],
slot_refs: &mut [Option<S>],
) -> Vec<usize> {
// `release` is the only way a slot is freed (SlotMap docs); the collect is
// because `held` borrows the map the releases mutate.
@@ -1279,10 +1292,55 @@ fn slot_view(state: &SessionStateH265, slot: u8) -> Option<vk::ImageView> {
/// (No derived equality: `StdVideoDecodeH265ReferenceInfo` is a plain-C bindgen
/// struct without it. Assertions compare the fields that carry meaning.)
#[derive(Debug, Clone, Copy)]
struct ScopeEntry {
slot_index: i32,
view: vk::ImageView,
std: hh::StdVideoDecodeH265ReferenceInfo,
pub(crate) struct ScopeEntry<S> {
pub(crate) slot_index: i32,
pub(crate) view: vk::ImageView,
pub(crate) std: S,
}
/// One of this AU's references, as [`build_scope`] needs to see it: a DPB slot and
/// the codec reference info to bind with it.
///
/// It exists so H.264 and H.265 share ONE scope builder instead of two hand-copies of
/// a function whose whole job is refusing to guess — the property most in need of a
/// single implementation. Their `VkRef`/`VkRefH265` differ only in the `StdVideo*`
/// type they carry, so the shape generalises exactly.
///
/// ⚠ AV1 deliberately keeps its own ([`crate::decoder_av1`]'s `build_scope_av1`): its
/// reference array is indexed by reference NAME and may hold HOLES, so its walk is a
/// different algorithm rather than the same one over a different Std type. Folding it
/// in here would mean a builder with a mode flag, which is how the two would drift.
pub(crate) trait ScopeRef {
/// The codec's `StdVideoDecode*ReferenceInfo`.
type Std: Copy;
/// The DPB slot this reference is bound in.
fn slot(&self) -> u8;
fn std(&self) -> Self::Std;
}
/// [`build_scope`]'s answer: the bound-slot list, and how many of its LEADING entries
/// are this AU's own references (the prefix the decode op takes as its reference
/// array — see the ordering note in `build_scope`'s docs).
pub(crate) type Scope<R> = (Vec<ScopeEntry<<R as ScopeRef>::Std>>, usize);
impl ScopeRef for crate::pic_h265::VkRefH265 {
type Std = hh::StdVideoDecodeH265ReferenceInfo;
fn slot(&self) -> u8 {
self.slot
}
fn std(&self) -> Self::Std {
self.std
}
}
impl ScopeRef for crate::pic::VkRef {
type Std = ash::vk::native::StdVideoDecodeH264ReferenceInfo;
fn slot(&self) -> u8 {
self.slot
}
fn std(&self) -> Self::Std {
self.std
}
}
/// Build the coding scope's bound-slot list and say how many leading entries are
@@ -1295,36 +1353,51 @@ struct ScopeEntry {
/// resources must stay bound even when this AU does not reference them);
/// 3. the setup slot as the activation entry, slot index `-1`.
///
/// A reference whose slot binds no image is a hard error, never a skip:
/// `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
/// `LtCurr` arrays name DPB slots, and every slot they name is one of `refs`'
/// ([`crate::pic_h265`]) so dropping an entry leaves the hardware with a named
/// slot this op never bound, which it can only answer by guessing or failing.
/// Output that looks plausible and is wrong is the outcome this refusal exists to
/// prevent.
fn build_scope(
refs: &[crate::pic_h265::VkRefH265],
/// A reference whose slot binds no image is a hard error, never a skip. For H.265 the
/// argument is `StdVideoDecodeH265PictureInfo`'s `RefPicSetStCurrBefore`/`StCurrAfter`/
/// `LtCurr` arrays: they name DPB slots, every slot they name is one of `refs`'
/// ([`crate::pic_h265`]), so dropping an entry leaves the hardware with a named slot
/// this op never bound which it can only answer by guessing or failing.
///
/// H.264 has no such index arrays, and it used to skip the case with a `trace!` on
/// exactly that reasoning. The reasoning was wrong about the OUTPUT: the hardware
/// still decodes a P-picture against a reference that was never bound, which on the
/// DPB-and-output-COINCIDE path is a gray plate with the new frame's motion painted
/// over it — and because the planner raised no warning (its DPB genuinely holds the
/// picture; the breakage is in this ledger), the frame was shipped, presented, and
/// cleared the consumer's demotion streak on its way past. Both codecs fail closed
/// here now; the recovery latch is what keeps failing closed from wedging the stream.
///
/// `reference_count` is captured the instant the `refs` loop ends, BEFORE the
/// held-slot pass appends anything. That ordering is load-bearing: the decode op takes
/// `scope[..reference_count]` as its reference list, so a count computed after the
/// second pass could hand it a still-held slot that this AU does not reference, in
/// place of one that failed to resolve. (Fail-closed above makes that unreachable —
/// but the construction must be correct on its own, not by depending on a check
/// somewhere else.)
pub(crate) fn build_scope<R: ScopeRef>(
refs: &[R],
held_slots: impl Iterator<Item = u8>,
setup_slot: u8,
setup_view: vk::ImageView,
setup_ref: hh::StdVideoDecodeH265ReferenceInfo,
slot_refs: &[Option<hh::StdVideoDecodeH265ReferenceInfo>],
setup_ref: R::Std,
slot_refs: &[Option<R::Std>],
view_of: impl Fn(u8) -> Option<vk::ImageView>,
) -> Result<(Vec<ScopeEntry>, usize), VkDecodeError> {
let mut scope: Vec<ScopeEntry> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
) -> Result<Scope<R>, VkDecodeError> {
let mut scope: Vec<ScopeEntry<R::Std>> = Vec::with_capacity(refs.len() + slot_refs.len() + 1);
for r in refs {
match view_of(r.slot) {
match view_of(r.slot()) {
Some(view) => scope.push(ScopeEntry {
slot_index: i32::from(r.slot),
slot_index: i32::from(r.slot()),
view,
std: r.std,
std: r.std(),
}),
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot }),
None => return Err(VkDecodeError::UnboundReferenceSlot { slot: r.slot() }),
}
}
let reference_count = scope.len();
for slot in held_slots {
if slot == setup_slot || refs.iter().any(|r| r.slot == slot) {
if slot == setup_slot || refs.iter().any(|r| r.slot() == slot) {
continue;
}
match (
@@ -1862,8 +1935,11 @@ mod tests {
let setup_slot = slots.assign(400).unwrap();
assert_eq!(setup_slot, 0, "the freed slots are assignable again");
slot_image[usize::from(setup_slot)] = Some(9);
// The empty slice needs its element type named now that `build_scope` is
// generic over the two codecs' reference types.
let no_refs: [VkRefH265; 0] = [];
let (scope, reference_count) = build_scope(
&[],
&no_refs,
slots.held().map(|(slot, _id)| slot),
setup_slot,
fake_view(setup_slot),
+15 -21
View File
@@ -38,19 +38,20 @@ use crate::{Av1PlanWarning, H265PlanWarning, PlanWarning};
/// 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 —
/// would be reported as clean and its picture shown. Invisible damage is the bug
/// this whole program exists to end; the compiler is the only reviewer guaranteed
/// to be present when that variant is written, so it gets the decision.
/// ⚠ The classification itself now lives on the warning enum, in pf-bitstream
/// ([`PlanWarning::is_integrity`]), and this function delegates. It moved there when
/// the planners gained the per-picture clean bit
/// ([`pf_bitstream::h264::PicturePlan::references_clean`]): that ledger has to mark a
/// picture damaged on exactly the warnings a consumer conceals on, and it lives one
/// crate DOWN from here. A copy of the list in each crate would let the two disagree —
/// the planner recording a picture as clean while the client concealed it, or the
/// reverse — which is the same invisible-damage failure the single-list rule below was
/// written to prevent, one layer lower. One list, in the crate that owns the enum.
///
/// This function stays as the crate's public spelling of the question (the fault
/// harness, the client and the tests all name it) and keeps its exact semantics.
pub fn is_integrity_warning(w: &PlanWarning) -> bool {
match w {
PlanWarning::FrameNumGap { .. }
| PlanWarning::MissingReference { .. }
| PlanWarning::TruncatedAu { .. } => true,
PlanWarning::Mmco5Rebase | PlanWarning::LevelDerivedDpb { .. } => false,
}
w.is_integrity()
}
/// The H.265 twin — the same set pf-bitstream's own `h265` conformance harness
@@ -59,10 +60,7 @@ pub fn is_integrity_warning(w: &PlanWarning) -> bool {
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new H.265 warning
/// must not be able to mean "damaged" and read as clean.
pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
match w {
H265PlanWarning::MissingReference { .. } | H265PlanWarning::TruncatedAu { .. } => true,
H265PlanWarning::NonZeroReorder { .. } => false,
}
w.is_integrity()
}
/// The AV1 twin (M7). Every variant the AV1 planner has today IS damage, and that
@@ -93,11 +91,7 @@ pub fn is_integrity_warning_h265(w: &H265PlanWarning) -> bool {
/// Exhaustive for the same reason as [`is_integrity_warning`]: a new AV1 warning
/// must not be able to mean "damaged" and read as clean.
pub fn is_integrity_warning_av1(w: &Av1PlanWarning) -> bool {
match w {
Av1PlanWarning::MissingReference { .. }
| Av1PlanWarning::MissingShowExisting { .. }
| Av1PlanWarning::TruncatedAu { .. } => true,
}
w.is_integrity()
}
#[cfg(test)]
+3
View File
@@ -899,6 +899,9 @@ mod tests {
max_dpb_frames,
short_term_ref_pic_set_size_bits: 0,
recovery_point: None,
// These fixtures model a healthy stream; the clean bit is the planner's
// observation and nothing in this conversion layer reads it.
references_clean: true,
}
}
+9
View File
@@ -5167,6 +5167,15 @@ pub unsafe extern "C" fn punktfunk_reanchor_gate_arm_expecting_drops(
/// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
/// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
///
/// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
/// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
/// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
/// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
/// decoder that surfaces no such fact, so it would have nothing to pass but
/// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
/// second export for a corroboration no C caller can supply would spend an ABI version bump on
/// dead surface.
///
/// # Safety
/// `g` is a valid gate handle; `out_present` is writable or NULL.
#[unsafe(no_mangle)]
+294 -3
View File
@@ -20,6 +20,30 @@
//! VideoToolbox, every FFmpeg rung, which exposes no SEI) simply never calls it and every wire
//! behaviour above is bit-for-bit unchanged.
//!
//! # The one claim a client can REFUTE
//!
//! Of the three lifts, two are self-evident to the client and one is pure hearsay. An IDR predicts
//! from nothing, so "this re-anchors decode" is a property of the picture itself. A recovery mark is
//! only *half* a re-anchor and the gate says so by requiring two. But
//! [`USER_FLAG_RECOVERY_ANCHOR`] is the HOST asserting a fact about the CLIENT's decoder — *the
//! picture I coded this P-frame against is one you still hold, intact* — and until
//! [`AnchorEvidence`] existed the client took it on faith, on the first occurrence, with no
//! scrutiny at all.
//!
//! When that assertion is wrong the failure is the worst-shaped one in this module: the anchor lifts
//! the freeze onto a picture predicted from a reference the client had to conceal, so the gray plate
//! reaches the screen AND the gate stops holding, which means it keeps reaching the screen until
//! some later signal re-arms. A re-anchor claim the client can refute is therefore worse than no
//! claim at all — no claim merely holds the last good frame until the backstop.
//!
//! So a client whose decoder parses the bitstream corroborates it: it already knows which pictures
//! this AU predicts from and whether each of those decoded from a complete reference chain, and
//! [`on_decoded_corroborated`](ReanchorGate::on_decoded_corroborated) refuses an anchor whose
//! references it can prove were damaged. Refusing can only ever make the gate hold LONGER — the
//! freeze stays up, the backstop fires on its ORIGINAL deadline, and the client escalates to a real
//! IDR — which is the direction every other rule here errs in, deliberately. Lanes that cannot
//! answer pass [`AnchorEvidence::Unavailable`] and behave exactly as they always have.
//!
//! [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
//! [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
@@ -97,8 +121,9 @@ pub fn index_gap(expected: u32, got: u32) -> Option<u32> {
/// Fold one decoded frame into the re-anchor state and decide whether it lifts the post-loss freeze.
///
/// `is_keyframe` — a real IDR (always a clean re-anchor). `has_anchor` — this AU carried
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR), the host's definitive
/// single-frame re-anchor from an LTR-RFI recovery (a clean P-frame coded against a known-good
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) **and the caller did not
/// refute it** ([`AnchorEvidence`]), the host's definitive single-frame re-anchor from an LTR-RFI
/// recovery (a clean P-frame coded against a known-good
/// reference), so it lifts on the FIRST occurrence exactly like an IDR — no two-mark wait. `has_mark` —
/// this AU carried [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT), a
/// host-signalled intra-refresh wave boundary (only *half* a re-anchor). `marks` — recovery marks seen
@@ -158,6 +183,44 @@ impl LocalRecovery {
};
}
/// What a client's OWN parser can say about the host's re-anchor claim on one decoded frame — the
/// corroboration for [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR).
///
/// An anchor is the host asserting something about the CLIENT's decoder: *this P-frame is coded
/// against a picture you still hold, intact, so decoding it re-anchors you*. The host derives that
/// from its own slot bookkeeping — which tracks whether the client RECEIVED a frame, not whether it
/// DECODED that frame from a complete reference chain. Those two differ exactly when the client had
/// to conceal, and the gap between them is what puts a gray plate on screen with the freeze lifted.
///
/// Three states rather than a bool, for the same reason [`LocalRecovery`] is two facts: a lane that
/// *cannot* answer must be able to say so instead of being folded into "nothing wrong here". Only
/// [`Self::ReferencesDamaged`] changes any behaviour; the other two are indistinguishable to the
/// gate and differ only in what they claim at the call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AnchorEvidence {
/// This lane has no local bitstream parser, so it cannot corroborate or refute anything — the
/// host's claim stands, exactly as it always has. Android MediaCodec, Apple VideoToolbox and
/// every lane reached over the C ABI pass this, and their behaviour is bit-for-bit unchanged.
#[default]
Unavailable,
/// Corroborated: every picture this AU predicts from was itself decoded from a fully-available
/// reference chain, so the host's claim is consistent with what this decoder actually holds.
ReferencesClean,
/// Refuted: this AU predicts from a picture that needed concealment. Whatever the host believes,
/// decoding this frame cannot re-anchor a decoder whose reference for it is already damaged, so
/// the anchor does not lift the freeze.
ReferencesDamaged,
}
impl AnchorEvidence {
/// May an [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on this frame
/// be honoured? Only an outright refutation withholds it — silence is not refutation, so a lane
/// that cannot corroborate never becomes *stricter* than it was.
fn honours_anchor(self) -> bool {
!matches!(self, AnchorEvidence::ReferencesDamaged)
}
}
/// Whether a decoded frame should be shown or withheld while the gate is (or isn't) frozen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateVerdict {
@@ -333,6 +396,11 @@ impl ReanchorGate {
/// A decoded frame always clears the no-output streak. When frozen, a live mark stream pushes the
/// backstop out ([`RECOVERY_MARK_PATIENCE`]) so a healing wave isn't pre-empted by a mid-heal IDR.
///
/// This is the whole-hearsay entry point: it believes an anchor on sight. A client whose decoder
/// parses the bitstream should call
/// [`on_decoded_corroborated`](Self::on_decoded_corroborated) instead and let its own parser
/// check the host's claim.
///
/// [`USER_FLAG_RECOVERY_ANCHOR`]: crate::packet::USER_FLAG_RECOVERY_ANCHOR
/// [`USER_FLAG_RECOVERY_POINT`]: crate::packet::USER_FLAG_RECOVERY_POINT
pub fn on_decoded(
@@ -340,10 +408,49 @@ impl ReanchorGate {
wire_flags: u32,
decoder_keyframe: bool,
now: Instant,
) -> GateVerdict {
self.on_decoded_corroborated(
wire_flags,
decoder_keyframe,
AnchorEvidence::Unavailable,
now,
)
}
/// [`on_decoded`](Self::on_decoded) for a client that can CHECK the host's re-anchor claim
/// against its own decoder — the native-decode lanes, which parse every AU themselves and so
/// know both which pictures this one predicts from and whether each of those decoded cleanly.
///
/// `evidence` is consulted for exactly one thing: whether a
/// [`USER_FLAG_RECOVERY_ANCHOR`](crate::packet::USER_FLAG_RECOVERY_ANCHOR) on THIS frame may
/// lift the freeze. [`AnchorEvidence::ReferencesDamaged`] withholds that lift and nothing else,
/// and the two exclusions are as deliberate as the rule itself:
///
/// * **A real IDR still lifts.** It predicts from nothing, so no evidence about its references
/// can bear on it — and the IDR is precisely the escalation a refused anchor is trying to
/// provoke. Refusing it would turn the fix into the permanent freeze it exists to avoid.
/// * **The two-mark [`USER_FLAG_RECOVERY_POINT`](crate::packet::USER_FLAG_RECOVERY_POINT) rule
/// is untouched**, including its [`RECOVERY_MARK_PATIENCE`] deadline push. An intra-refresh
/// wave heals by overwriting stripes rather than by predicting from one named picture, so
/// "this frame's references were damaged" says nothing about whether the wave completed.
///
/// A refused anchor also leaves the backstop deadline exactly where the arm put it. That is the
/// point rather than an omission: the freeze becomes overdue on its ORIGINAL schedule, [`poll`](Self::poll)
/// re-asks, and the client escalates to a real IDR — the recovery the host's anchor failed to
/// deliver. Pushing the deadline out on a refusal would reward a host whose anchors do not work
/// with a longer wait.
pub fn on_decoded_corroborated(
&mut self,
wire_flags: u32,
decoder_keyframe: bool,
evidence: AnchorEvidence,
now: Instant,
) -> GateVerdict {
self.no_output_streak = 0;
let is_keyframe = decoder_keyframe || (wire_flags & FLAG_SOF as u32 != 0);
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0;
// An anchor the client's own parser refutes is not an anchor. Folded in HERE rather than
// inside `reanchor_after_frame` so that function stays a pure statement of the wire rules.
let has_anchor = wire_flags & USER_FLAG_RECOVERY_ANCHOR != 0 && evidence.honours_anchor();
let has_mark = wire_flags & USER_FLAG_RECOVERY_POINT != 0;
if has_mark && self.awaiting {
self.deadline = Some(now + RECOVERY_MARK_PATIENCE);
@@ -888,4 +995,188 @@ mod tests {
assert!(!g.poll(0, t + Duration::from_millis(1)));
assert!(g.is_holding());
}
// ---- the corroborated-anchor path (AnchorEvidence) ----
use AnchorEvidence::{ReferencesClean, ReferencesDamaged, Unavailable};
/// The headline. The host says "this P-frame re-anchors you"; the client's own parser says the
/// picture it predicts from is one IT had to conceal. Both cannot be true, and the client's
/// statement is about its OWN decoder — so the anchor does not lift and the gray plate the
/// anchor would have presented never reaches the screen.
#[test]
fn an_anchor_whose_references_the_decoder_concealed_does_not_lift() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold,
"a refuted anchor is not a re-anchor"
);
assert!(g.is_holding(), "and the freeze stays up");
// Repeating it changes nothing — a host that keeps sending anchors it cannot honour never
// talks its way past the gate.
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert!(g.is_holding());
}
/// The escalation a refusal exists to provoke must still work. An IDR predicts from nothing, so
/// no evidence about damaged references can bear on it — refusing it too would convert this fix
/// into the permanent freeze it is meant to avoid.
#[test]
fn a_real_idr_lifts_even_while_the_evidence_refutes_anchors() {
// The decoder's own keyframe flag...
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(0, true, ReferencesDamaged, now),
GateVerdict::Present,
"the IDR re-anchors regardless of what the anchor evidence says"
);
assert!(!g.is_holding());
// ...and the wire's FLAG_SOF, for the lanes whose decoder does not flag IDRs.
let mut g = ReanchorGate::new(0);
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(SOF, false, ReferencesDamaged, now),
GateVerdict::Present
);
assert!(!g.is_holding());
}
/// A corroborated anchor is still an anchor: the whole point is to refuse the ones the client
/// can disprove, not to stop honouring the mechanism.
#[test]
fn a_corroborated_anchor_lifts_on_the_first_occurrence() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(0, false, ReferencesClean, now),
GateVerdict::Hold,
"an ordinary frame is still withheld"
);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesClean, now),
GateVerdict::Present
);
assert!(!g.is_holding());
}
/// `Unavailable` is the promise made to every lane without a local parser: silence is not
/// refutation. This walks the same sequences the wire-path tests above assert and requires the
/// identical verdicts through the corroborated entry point.
#[test]
fn an_uncorroborated_lane_behaves_exactly_as_it_always_has() {
// The anchor lift, byte for byte the `a_gap_lifts_on_the_first_rfi_anchor` contract.
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(0, false, Unavailable, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, Unavailable, now),
GateVerdict::Present
);
assert!(!g.is_holding());
// And `on_decoded` — which every such lane actually calls — must agree with it exactly.
let mut wire = ReanchorGate::new(0);
let mut corroborated = ReanchorGate::new(0);
wire.arm(now);
corroborated.arm(now);
for flags in [0, POINT, 0, ANCHOR, SOF, 0] {
assert_eq!(
wire.on_decoded(flags, false, now),
corroborated.on_decoded_corroborated(flags, false, Unavailable, now),
"flags {flags:#x} diverged between the two entry points"
);
assert_eq!(wire.is_holding(), corroborated.is_holding());
}
}
/// A refused anchor must not buy the host time. The freeze becomes overdue on the deadline the
/// ARM set — not one pushed out by the refusal — so the client escalates to the real IDR that
/// the failed anchor did not deliver.
#[test]
fn a_refused_anchor_leaves_the_backstop_on_its_original_deadline() {
let mut g = ReanchorGate::new(0);
let start = t0();
g.arm(start);
// Anchors keep arriving and keep being refused, right up to the deadline.
for ms in [10, 100, 300, 490] {
assert_eq!(
g.on_decoded_corroborated(
ANCHOR,
false,
ReferencesDamaged,
start + Duration::from_millis(ms)
),
GateVerdict::Hold
);
assert!(!g.poll(0, start + Duration::from_millis(ms)), "not yet due");
}
let overdue = start + REANCHOR_FREEZE_MAX + Duration::from_millis(1);
assert!(
g.poll(0, overdue),
"the backstop fires on the arm's own deadline — the refusals did not extend it"
);
assert!(
g.is_holding(),
"and it keeps holding, never resuming to gray"
);
}
/// Refuting an anchor says nothing about an intra-refresh wave: a wave heals by overwriting
/// stripes rather than by predicting from one named picture, so the two-mark rule and its
/// patience deadline must be untouched by the evidence.
#[test]
fn refuted_anchors_do_not_disturb_the_two_mark_rule() {
let mut g = ReanchorGate::new(0);
let now = t0();
g.arm(now);
assert_eq!(
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
GateVerdict::Hold,
"mark #1 is still only half a re-anchor"
);
// An anchor in between is refused and must not consume or reset the mark count.
assert_eq!(
g.on_decoded_corroborated(ANCHOR, false, ReferencesDamaged, now),
GateVerdict::Hold
);
assert_eq!(
g.on_decoded_corroborated(POINT, false, ReferencesDamaged, now),
GateVerdict::Present,
"mark #2 lifts exactly as it does on the wire path"
);
assert!(!g.is_holding());
}
/// The evidence is consulted only while an anchor flag is actually present — a refutation on an
/// ordinary frame must not become a second, sticky reason to hold.
#[test]
fn damaged_evidence_alone_neither_holds_nor_arms_an_unfrozen_gate() {
let mut g = ReanchorGate::new(0);
let now = t0();
assert_eq!(
g.on_decoded_corroborated(0, false, ReferencesDamaged, now),
GateVerdict::Present,
"an unfrozen gate presents; the evidence is about anchors, not about frames"
);
assert!(!g.is_holding());
assert!(!g.poll(0, now));
}
}
+33 -2
View File
@@ -2893,15 +2893,46 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
let rfi_echo = last_rfi.is_some_and(|t| t.elapsed() < RFI_ECHO_WINDOW)
&& rfi_echo_swallowed < RFI_ECHO_MAX_SWALLOWED;
if idr_recent {
tracing::debug!("keyframe request coalesced — within the IDR cooldown");
// Coalesced, and the client is STILL reporting damage — so whatever the in-flight
// IDR will repair, it has not repaired yet, and until it lands no reference in the
// table can honestly be called known-good to this client. Withdraw anchor trust for
// the duration: without it, a frame-index gap arriving inside this window is
// answered with an RFI anchor picked over exactly that unrepaired damage, and the
// anchor lifts the client's post-loss freeze on its first occurrence — grey frames,
// presented, freeze lifted. The cost is bounded to nothing that matters: the IDR
// this branch is waiting on rebuilds trust from scratch when it lands (it flushes
// the DPB), and prediction never used the wire domain in the first place.
enc.distrust_references();
tracing::debug!(
"keyframe request coalesced — within the IDR cooldown; RFI anchor trust \
withdrawn until the IDR repairs the client"
);
} else if rfi_echo {
// Deliberately NO distrust here, and it is the one branch where that would be
// wrong. This branch's whole premise is that the request is the client's ECHO of
// the loss the RFI just repaired — the recovery frame is still in flight. Withdraw
// trust on the first echo and every successful RFI recovery poisons the table for
// the next one, so RFI could never fire twice running and a sustained-loss session
// falls straight back to the IDR path this block exists to keep it off. The premise
// is a guess, and `RFI_ECHO_MAX_SWALLOWED` is already its hedge: when the client
// keeps asking past the budget the guess was wrong, and the `else` arm below both
// serves the IDR and withdraws trust then — on evidence rather than on suspicion.
rfi_echo_swallowed += 1;
tracing::debug!(
swallowed = rfi_echo_swallowed,
"keyframe request coalesced — echo of an RFI-recovered loss"
);
} else {
tracing::debug!("forcing keyframe (client decode recovery)");
// Did we get here THROUGH exhausted echo-swallowing? Then this episode's RFI
// anchor demonstrably did not heal the client: we presumed its requests were
// echoes, swallowed them, and it kept asking anyway. Serving the IDR (below) fixes
// this loss; withdrawing trust is what stops the same un-healing reference being
// picked as the anchor for the NEXT one. Read before the reset that follows.
let rfi_unhealed = rfi_echo_swallowed > 0;
tracing::debug!(rfi_unhealed, "forcing keyframe (client decode recovery)");
if rfi_unhealed {
enc.distrust_references();
}
enc.request_keyframe();
last_forced_idr = Some(now);
rfi_echo_swallowed = 0; // the IDR resets the episode — echoes of IT coalesce via the cooldown
+9
View File
@@ -3774,6 +3774,15 @@ void punktfunk_reanchor_gate_arm_expecting_drops(ReanchorGate *g, uint64_t expec
// `USER_FLAG_RECOVERY_POINT`. Pass `decoder_keyframe = false` where the platform decoder doesn't flag
// IDRs (VideoToolbox/MediaCodec) — the wire `FLAG_SOF` covers it.
//
// This is the uncorroborated entry point and deliberately stays that way. Rust embedders whose
// decoder parses the bitstream call [`ReanchorGate::on_decoded_corroborated`] to let their own
// parser refute a `USER_FLAG_RECOVERY_ANCHOR` that names a picture they had to conceal; every
// client reachable through THIS surface (Apple VideoToolbox, Android MediaCodec) uses a platform
// decoder that surfaces no such fact, so it would have nothing to pass but
// `AnchorEvidence::Unavailable` — which is exactly what this wrapper already means. Growing a
// second export for a corroboration no C caller can supply would spend an ABI version bump on
// dead surface.
//
// # Safety
// `g` is a valid gate handle; `out_present` is writable or NULL.
PunktfunkStatus punktfunk_reanchor_gate_on_decoded(ReanchorGate *g,