refactor(pf-encode): extract the slot-family RFI recovery policy (WP7.2)

AMF (user-LTR bitfield), QSV (mfxExtRefListCtrl) and Vulkan Video (the
app-owned DPB slot table) each hand-implemented the same loss-recovery
decision: distrust every reference encoded at-or-after the loss start,
anchor on the newest one strictly older. Three copies had already
diverged once — the fecbec2d taint sweep reached AMF/QSV a commit before
the Vulkan backend was carved out, and Vulkan shipped without it. The
decision now lives once in enc/rfi.rs, pure and unit-tested (this path
had zero coverage and is the loss-recovery path); every mechanism —
how a force is applied, how distrust is persisted — stays in its
backend.

Behavior-preservation notes, each critic-verified against the shipped
code:
- Callers feed only currently-trusted references; taint (>= loss) and
  anchor (< loss) predicates are disjoint, so pre-sweep-view pick ==
  post-sweep-table pick on every input. Tie-break preserved (first
  entry wins == the strict '>' all three used, ascending slot order).
- plan_slot_recovery delegates its pick half to pick_anchor, which
  keeps both items live on every leg (pick_anchor's only external
  caller is Linux+vulkan-encode; dead_code is an item lint) and makes
  'anchor chosen from the taint snapshot' structural.
- The decline arms are deliberately NOT uniform and stay put: AMF/QSV
  clear an un-consumed pending_force; Vulkan leaves pending_loss armed
  (a stale arm re-resolves at frame-build into the healing IDR) — now
  pinned in comments on both sides.
- Vulkan's frame-build site is a pick-only re-run by design (the arm
  carries the loss start, not the slot); pick_recovery_slot is deleted
  and its tests migrated 1:1 into the shared module.

Test-count deltas are expected: the rfi tests now run on every Windows
combo (amf.rs is featureless there) and on the Linux feature leg.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 00:08:03 +02:00
co-authored by Claude Opus 5
parent d28fb1282b
commit 9f1e648e4e
5 changed files with 271 additions and 151 deletions
+39 -101
View File
@@ -363,17 +363,15 @@ impl Default for BsPtr {
} }
} }
/// Newest resident DPB slot whose wire index is strictly older than the loss — the clean anchor. /// The trusted-reference view of `slot_wire` for the shared RFI policy ([`crate::rfi`]): resident
fn pick_recovery_slot(slot_wire: &[i64], loss_first: i64) -> Option<usize> { /// slots only — `-1` marks empty *or distrusted* (blanked by an earlier loss's taint sweep), and
let mut best: Option<usize> = None; /// excluding it here is exact because the loss start is validity-gated non-negative.
let mut best_wire = -1i64; fn trusted_refs(slot_wire: &[i64]) -> Vec<(usize, i64)> {
for (i, &w) in slot_wire.iter().enumerate() { slot_wire
if w >= 0 && w < loss_first && w > best_wire { .iter()
best = Some(i); .enumerate()
best_wire = w; .filter_map(|(s, &w)| (w >= 0).then_some((s, w)))
} .collect()
}
best
} }
/// The S0 (past-reference) half of an HEVC short-term RPS that **retains every resident DPB /// The S0 (past-reference) half of an HEVC short-term RPS that **retains every resident DPB
@@ -385,7 +383,8 @@ fn pick_recovery_slot(slot_wire: &[i64], loss_first: i64) -> Option<usize> {
/// with a generated gray reference and every following frame chains off the corruption — exactly /// with a generated gray reference and every following frame chains off the corruption — exactly
/// at the moment the anchor claims the picture is clean. Listing all residents (with /// at the moment the anchor claims the picture is clean. Listing all residents (with
/// `used_by_curr_pic` set only for the real reference) keeps the host and client DPBs in lockstep, /// `used_by_curr_pic` set only for the real reference) keeps the host and client DPBs in lockstep,
/// so any slot [`pick_recovery_slot`] can pick is decodable by construction. /// so any slot the RFI anchor pick ([`crate::rfi::pick_anchor`]) can pick is decodable by
/// construction.
/// ///
/// `setup_idx` — the slot this frame reconstructs into — is excluded: its old occupant dies with /// `setup_idx` — the slot this frame reconstructs into — is excluded: its old occupant dies with
/// this frame on the host, so the decoder must drop it too (also keeping the retained count at /// this frame on the host, so the decoder must drop it too (also keeping the retained count at
@@ -1950,8 +1949,11 @@ impl VulkanVideoEncoder {
let mut recovery = false; let mut recovery = false;
if let Some(lf) = self.pending_loss.take() { if let Some(lf) = self.pending_loss.take() {
if !is_idr { if !is_idr {
match pick_recovery_slot(&self.slot_wire, lf) { // Pick-only re-run of the shared policy: the taint sweep already happened in
Some(s) => { // `invalidate_ref_frames`; the arm carries the loss start, not the slot, so the
// anchor is resolved against the table as it stands NOW.
match crate::rfi::pick_anchor(&trusted_refs(&self.slot_wire), lf) {
Some((s, _)) => {
ref_slot = s; ref_slot = s;
recovery = true; recovery = true;
tracing::debug!( tracing::debug!(
@@ -3705,29 +3707,31 @@ impl Encoder for VulkanVideoEncoder {
if first_frame < 0 || first_frame > last_frame { if first_frame < 0 || first_frame > last_frame {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor (the fecbec2d fix AMF and QSV got; this backend was // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
// carved out one commit later and never received it). "Resident and older than THIS loss" is // shared with AMF and QSV — the fecbec2d sweep reached those two a commit before this
// not the same as "the client decoded it": after an earlier loss [a,b] was recovered at wire // backend was carved out, and the hand-copy here shipped without it). Why the sweep:
// r, everything in [a, r-1] is undecodable at the client — the lost frames plus every frame // "resident and older than THIS loss" is not the same as "the client decoded it" — after
// that predicted through the gap. Those wires stay valid anchor candidates here until the // an earlier loss [a,b] was recovered at wire r, everything in [a, r-1] is undecodable at
// 8-slot ring rolls them out, so a LATER loss can anchor on one and ship corruption tagged // the client, yet those wires stay anchor candidates until the 8-slot ring rolls them
// `recovery_anchor` — which is the client's definitive re-anchor signal (reanchor.rs), so it // out, so a LATER loss could anchor on one and ship corruption tagged `recovery_anchor` —
// lifts the post-loss freeze onto a picture built from a reference it never had. // the client's definitive re-anchor signal (reanchor.rs).
// //
// Blank `slot_wire` ONLY. `slot_poc` must keep naming every physically-resident DPB picture // This backend's mechanism: distrust = blank `slot_wire` ONLY. `slot_poc` must keep
// for `build_h265_rps_s0`, or a conforming decoder evicts them and the anchor references a // naming every physically-resident DPB picture for `build_h265_rps_s0`, or a conforming
// picture the client already dropped. `slot_wire` is the RFI/loss domain; `slot_poc` is the // decoder evicts them and the anchor references a picture the client already dropped.
// reference-delta domain. `prev_slot` and the normal P-frame path are indices, not wires, so // `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain.
// ordinary prediction is unaffected. // `prev_slot` and the normal P-frame path are indices, not wires, so ordinary prediction
for w in self.slot_wire.iter_mut() { // is unaffected.
if *w >= first_frame { let plan = crate::rfi::plan_slot_recovery(&trusted_refs(&self.slot_wire), first_frame);
for (s, w) in self.slot_wire.iter_mut().enumerate() {
if plan.tainted & (1 << s) != 0 {
*w = -1; *w = -1;
} }
} }
// Can we anchor a clean P-frame to a resident slot strictly older than the loss? // Can we anchor a clean P-frame to a resident slot strictly older than the loss?
// (A sweep that empties every candidate yields `None` here and declines the RFI, matching // (A sweep that empties every candidate yields `None` here and declines the RFI, matching
// `qsv_live_ltr_rfi_taint_sweep_declines`.) // `qsv_live_ltr_rfi_taint_sweep_declines`.)
match pick_recovery_slot(&self.slot_wire, first_frame) { match plan.anchor {
Some(_) => { Some(_) => {
self.pending_loss = Some(first_frame); self.pending_loss = Some(first_frame);
true true
@@ -3736,6 +3740,10 @@ impl Encoder for VulkanVideoEncoder {
// Decline WITHOUT self-arming an IDR: the caller owns the fallback, and its // Decline WITHOUT self-arming an IDR: the caller owns the fallback, and its
// keyframe path is cooldown-coalesced — arming `force_kf` here would bypass that // keyframe path is cooldown-coalesced — arming `force_kf` here would bypass that
// and turn a storm of hopeless RFI requests into one full IDR per request. // and turn a storm of hopeless RFI requests into one full IDR per request.
// `pending_loss` is DELIBERATELY left armed (unlike AMF/QSV's pending_force
// clear): a stale arm is re-resolved at frame-build, where a failed re-pick
// forces the IDR that heals the stream — clearing it here would ship an untagged
// plain P during the caller's RFI-echo window instead.
tracing::debug!( tracing::debug!(
first_frame, first_frame,
last_frame, last_frame,
@@ -4855,80 +4863,10 @@ unsafe fn build_parameters_av1(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{build_h265_rps_s0, parse_rgb_request, pick_recovery_slot, VulkanVideoEncoder}; use super::{build_h265_rps_s0, parse_rgb_request, VulkanVideoEncoder};
use crate::{Codec, Encoder}; use crate::{Codec, Encoder};
use pf_frame::{CapturedFrame, FramePayload, PixelFormat}; use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
/// slots never qualify.
#[test]
fn recovery_slot_picks_newest_pre_loss() {
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
assert_eq!(pick_recovery_slot(&wires, 9), Some(0));
// loss older than everything resident → no anchor (caller keyframes).
assert_eq!(pick_recovery_slot(&wires, 5), None);
// empty slots (-1) are skipped.
assert_eq!(pick_recovery_slot(&[-1, 3, -1, 4], 5), Some(3));
assert_eq!(pick_recovery_slot(&[-1; 8], 5), None);
}
/// The taint sweep (fecbec2d's fix, ported here): a slot encoded inside an EARLIER, still
/// unrepaired loss window must not become the "known-good" anchor of a LATER loss. Without the
/// sweep, `pick_recovery_slot` accepts it — it is resident and its wire is below the second
/// loss start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto
/// a reference it never decoded.
#[test]
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
// Apply the sweep exactly as `invalidate_ref_frames` does.
fn sweep(wires: &mut [i64], loss_first: i64) {
for w in wires.iter_mut() {
if *w >= loss_first {
*w = -1;
}
}
}
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
// client. A second loss report arrives at wire 6 while they are all still resident.
let tainted = [4i64, 5, 6, 7];
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
let picked = pick_recovery_slot(&unswept, 6).expect("unswept picks something");
assert!(
tainted.contains(&unswept[picked]),
"precondition: without the sweep the anchor comes from the earlier loss window"
);
// WITH the sweep, loss 1 blanks 4..7, so loss 2 can only reach genuinely clean wires.
let mut wires = unswept;
sweep(&mut wires, 4);
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
let picked = pick_recovery_slot(&wires, 6).expect("clean wires remain");
assert_eq!(picked, 3, "newest clean survivor is wire 3");
assert!(!tainted.contains(&wires[picked]));
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
wires[4] = 8;
wires[5] = 9;
wires[6] = 10;
wires[7] = 11;
sweep(&mut wires, 10);
assert_eq!(
pick_recovery_slot(&wires, 10),
Some(5),
"wire 9 is post-recovery, clean"
);
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
sweep(&mut all, 5);
assert_eq!(pick_recovery_slot(&all, 5), None);
}
/// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the /// The full-retention RPS: every resident picture is listed (so the decoder keeps it), the
/// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference. /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference.
#[test] #[test]
+178
View File
@@ -0,0 +1,178 @@
//! The slot-family RFI (reference-frame invalidation) recovery **policy**, shared by the three
//! backends that answer a loss with a re-reference to a known-good older frame instead of an IDR:
//! native AMF (user-LTR bitfield), native QSV (`mfxExtRefListCtrl` LTR) and Vulkan Video (the
//! app-owned DPB slot table). One decision, three mechanisms — the policy lived as three
//! hand-copies and had already diverged once (the fecbec2d taint sweep reached AMF/QSV a commit
//! before the Vulkan backend was carved out, and Vulkan shipped without it until a later fix).
//! The NVENC twins' *range* policy is the other half of WP7.2, in
//! [`super::nvenc_core::plan_range_recovery`].
//!
//! Policy only. Every mechanism — how a force is applied, how distrust is *persisted* (AMF clears
//! its mirror slot to `None`, QSV sets a separate `ltr_tainted` flag because its mirror must keep
//! naming the slot for the RejectedRefList, Vulkan blanks `slot_wire` to `-1` while `slot_poc`
//! keeps the picture resident for the RPS) — stays in its backend. Callers feed their
//! **currently-trusted** references and apply the returned taints through their own marker; that
//! caller-side filter is exactly what makes the three persistence schemes equivalent under one
//! pure function.
//!
//! The decline arm is also the backend's: AMF/QSV clear an un-consumed `pending_force` (the sweep
//! may have emptied the slot it points at), while Vulkan deliberately leaves `pending_loss` armed
//! (a stale arm is re-resolved at frame-build, where a failed re-pick forces the IDR that heals
//! the stream). Do not harmonize them here.
/// One loss event's recovery decision over a slot table: which trusted references become
/// untrustworthy, and which one anchors the recovery.
pub(super) struct SlotPlan {
/// Bitmask of slots whose reference was encoded **at or after** the loss start — inside the
/// client's corrupt window, so the client either never received it or decoded it against a
/// broken chain. Serving one as "known-good" on a LATER loss ships corruption tagged as the
/// recovery anchor; the backend must record the distrust in its own persistent marker, because
/// these wires would otherwise look like valid pre-loss anchors to the next loss event.
pub(super) tainted: u32,
/// The recovery anchor: the newest trusted reference **strictly older** than the loss —
/// `(slot, wire)` — i.e. the most recent picture the client still holds intact, so
/// re-referencing it costs the smallest residual. `None`: every candidate is inside or after
/// the corrupt window — the caller declines and its (coalesced) keyframe path recovers.
pub(super) anchor: Option<(usize, i64)>,
}
/// Plan the recovery for a loss starting at wire frame `loss_first`, over the backend's
/// currently-trusted references (`(slot, wire)`; previously-distrusted entries must already be
/// filtered out by the caller — see the module doc). Sweep and pick are one call so the anchor is
/// chosen from the same snapshot the taints are computed from, by construction: the anchor
/// delegates to [`pick_anchor`], and `wire >= loss_first` (taint) and `wire < loss_first`
/// (anchor) are disjoint, so a slot tainted by this call can never be this call's anchor.
pub(super) fn plan_slot_recovery(refs: &[(usize, i64)], loss_first: i64) -> SlotPlan {
// The callers' validity gate (`first < 0 → decline`) is what makes their sentinel filters
// (-1 / `None`) exact views of "trusted"; this assert keeps the contract visible from inside
// the extracted code. Plain assert: the lint legs run --release, and a compiled-out check
// here would silently drop taints instead of failing loudly.
assert!(
loss_first >= 0,
"loss_first must be validity-gated by the caller"
);
let mut tainted = 0u32;
for &(slot, wire) in refs {
if wire >= loss_first {
assert!(slot < 32, "slot table exceeds the u32 taint mask");
tainted |= 1 << slot;
}
}
SlotPlan {
tainted,
anchor: pick_anchor(refs, loss_first),
}
}
/// The pick half alone: newest trusted reference strictly older than the loss. Ties break to the
/// first entry in `refs` (every caller feeds ascending slot order, so the lowest slot wins —
/// the strict `>` all three backends used). Standalone because Vulkan re-picks at frame-build
/// time: its arm carries the loss start, not the slot, so the slot is resolved against the table
/// as it stands when the recovery frame is actually encoded.
pub(super) fn pick_anchor(refs: &[(usize, i64)], loss_first: i64) -> Option<(usize, i64)> {
let mut best: Option<(usize, i64)> = None;
for &(slot, wire) in refs {
if wire < loss_first && best.is_none_or(|(_, b)| wire > b) {
best = Some((slot, wire));
}
}
best
}
#[cfg(test)]
mod tests {
use super::{pick_anchor, plan_slot_recovery};
/// Adapt a raw slot table (the Vulkan `slot_wire` shape: `-1` = empty) into the trusted view
/// the policy takes — the same filter the backend adapters apply.
fn view(wires: &[i64]) -> Vec<(usize, i64)> {
wires
.iter()
.enumerate()
.filter_map(|(s, &w)| (w >= 0).then_some((s, w)))
.collect()
}
/// Apply a plan's taints the way the Vulkan adapter does (blank the wire) — the persistence
/// half the pure fn hands back to the caller.
fn apply(wires: &mut [i64], tainted: u32) {
for (s, w) in wires.iter_mut().enumerate() {
if tainted & (1 << s) != 0 {
*w = -1;
}
}
}
/// The RFI anchor picker: newest resident wire strictly older than the loss; empty/newer
/// slots never qualify. (Migrated 1:1 from `vulkan_video.rs`'s `pick_recovery_slot` tests —
/// same vectors, now `(slot, wire)`-valued.)
#[test]
fn picks_newest_pre_loss() {
// slots hold wires 5..12 (ring position arbitrary); loss starts at 9 → anchor = wire 8.
let wires = [8i64, 9, 10, 11, 12, 5, 6, 7];
assert_eq!(pick_anchor(&view(&wires), 9), Some((0, 8)));
// loss older than everything resident → no anchor (caller keyframes).
assert_eq!(pick_anchor(&view(&wires), 5), None);
// empty slots (-1) are skipped by the view filter and never anchor.
assert_eq!(pick_anchor(&view(&[-1, 3, -1, 4]), 5), Some((3, 4)));
assert_eq!(pick_anchor(&view(&[-1; 8]), 5), None);
// wire == loss_first is INSIDE the corrupt window: strictly-older only.
assert_eq!(pick_anchor(&view(&[9, 8]), 9), Some((1, 8)));
// tie on the wire → first entry (= lowest slot) wins, the strict `>` all three backends
// used.
assert_eq!(pick_anchor(&[(2, 7), (5, 7)], 9), Some((2, 7)));
// empty view.
assert_eq!(pick_anchor(&[], 9), None);
}
/// The taint sweep (fecbec2d's fix): a slot encoded inside an EARLIER, still unrepaired loss
/// window must not become the "known-good" anchor of a LATER loss. Without persisted
/// distrust the picker accepts it — it is resident and its wire is below the second loss
/// start — and the frame ships tagged `recovery_anchor`, lifting the client's freeze onto a
/// reference it never decoded. (Migrated 1:1 from `vulkan_video.rs`; the hand-replicated
/// sweep is now `plan_slot_recovery` itself.)
#[test]
fn taint_sweep_excludes_slots_from_an_earlier_loss() {
// Slots hold wires 0..7. Loss 1 starts at wire 4, so wires 4..7 are undecodable at the
// client. A second loss report arrives at wire 6 while they are all still resident.
let tainted_wires = [4i64, 5, 6, 7];
// WITHOUT the sweep this is the bug: the newest wire below 6 is wire 5 — squarely inside
// loss 1's unrepaired window — and it would be served as the "known-good" anchor.
let unswept = [0i64, 1, 2, 3, 4, 5, 6, 7];
let (_, picked_wire) = pick_anchor(&view(&unswept), 6).expect("unswept picks something");
assert!(
tainted_wires.contains(&picked_wire),
"precondition: without the sweep the anchor comes from the earlier loss window"
);
// WITH the plan, loss 1 taints 4..7 (and anchors on wire 3 — never a tainted wire, by
// the disjoint predicates), so loss 2 can only reach genuinely clean wires.
let mut wires = unswept;
let plan = plan_slot_recovery(&view(&wires), 4);
assert_eq!(plan.tainted, 0b1111_0000);
assert_eq!(plan.anchor, Some((3, 3)));
apply(&mut wires, plan.tainted);
assert_eq!(wires, [0, 1, 2, 3, -1, -1, -1, -1]);
let (slot, wire) = pick_anchor(&view(&wires), 6).expect("clean wires remain");
assert_eq!((slot, wire), (3, 3), "newest clean survivor is wire 3");
// Encoding resumes after recovery; wires 8..11 refill the swept slots and are clean. A
// later loss at wire 10 legitimately anchors on wire 9 — the sweep must not over-reject.
wires[4] = 8;
wires[5] = 9;
wires[6] = 10;
wires[7] = 11;
let plan = plan_slot_recovery(&view(&wires), 10);
assert_eq!(plan.anchor, Some((5, 9)), "wire 9 is post-recovery, clean");
apply(&mut wires, plan.tainted);
// A loss covering every live wire leaves nothing clean → decline, caller serves an IDR.
let mut all = [5i64, 6, 7, 8, 9, 10, 11, 12];
let plan = plan_slot_recovery(&view(&all), 5);
assert_eq!(plan.tainted, 0b1111_1111);
assert_eq!(plan.anchor, None);
apply(&mut all, plan.tainted);
assert_eq!(pick_anchor(&view(&all), 5), None);
}
}
+18 -22
View File
@@ -2373,31 +2373,27 @@ impl Encoder for AmfEncoder {
if !self.ltr_active || first < 0 || first > last { if !self.ltr_active || first < 0 || first > last {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
// encoded inside the client's corrupt window — the client either never received it or // shared with QSV and Vulkan Video); this backend's mechanism is: distrust = clear the
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER // mirror slot (dropped slots stay dropped; the cadence re-marks a clean frame within
// loss ships corruption as the recovery anchor (and every subsequent mark re-samples // ~1/4 s). `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed`
// it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. // pins `frame_idx` to it per submission), so they compare directly against the client's
for marked in self.ltr_slots.iter_mut() { // `first` — and stay comparable across encoder rebuilds/resets, where an internal counter
if marked.is_some_and(|idx| idx >= first) { // would make the pre-loss check vacuous and risk force-referencing an LTR marked INSIDE
// the lost range.
let view: Vec<(usize, i64)> = self
.ltr_slots
.iter()
.enumerate()
.filter_map(|(s, m)| m.map(|w| (s, w)))
.collect();
let plan = super::rfi::plan_slot_recovery(&view, first);
for (slot, marked) in self.ltr_slots.iter_mut().enumerate() {
if plan.tainted & (1 << slot) != 0 {
*marked = None; *marked = None;
} }
} }
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the match plan.anchor {
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
// `ltr_slots` store the WIRE frame index of the marked frame (`submit_indexed` pins
// `frame_idx` to it per submission), so they compare directly against the client's `first`
// — and stay comparable across encoder rebuilds/resets, where an internal counter would
// make this check vacuous and risk force-referencing an LTR marked INSIDE the lost range.
let mut best: Option<(usize, i64)> = None;
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if let Some(idx) = *marked {
if idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx));
}
}
}
match best {
Some((slot, ltr_frame)) => { Some((slot, ltr_frame)) => {
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`. // Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
self.pending_force = Some(slot); self.pending_force = Some(slot);
+22 -28
View File
@@ -1416,36 +1416,30 @@ impl Encoder for QsvEncoder {
if !self.ltr_active || first < 0 || first > last { if !self.ltr_active || first < 0 || first > last {
return false; return false;
} }
// Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision
// encoded inside the client's corrupt window — the client either never received it or // shared with AMF and Vulkan Video). This backend's mechanism: distrust is a SEPARATE
// decoded it against a broken reference chain. Serving it as "known-good" on a LATER // `ltr_tainted` flag, never a cleared mirror slot — `ltr_slots` mirrors the HARDWARE DPB,
// loss ships corruption as the recovery anchor, and every subsequent mark re-samples // and nulling an entry issues no VPL call, so the frame stays marked long-term in the
// the soup — the sustained-loss field failure where the picture never healed. Dropped // encoder. Clearing it made the rejection list below (which iterates the mirror and only
// slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. // names `Some` slots) silently SKIP the one entry the sweep exists to distrust, so the
// // recovery frame could still predict from it. With two slots the "exactly one swept" case
// Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an // is the modal one, and it was the broken one. The `!ltr_tainted` view filter below is
// entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it // what persists that distrust across loss events (this call's taints are excluded from
// made the rejection list below (which iterates the post-sweep mirror and only names `Some` // this call's anchor by the policy itself).
// slots) silently SKIP the one entry the sweep exists to distrust, so the recovery frame let view: Vec<(usize, i64)> = self
// could still predict from it. With two slots the "exactly one swept" case is the modal .ltr_slots
// one, and it was the broken one. .iter()
for (slot, marked) in self.ltr_slots.iter().enumerate() { .enumerate()
if marked.is_some_and(|idx| idx >= first) { .filter(|&(slot, _)| !self.ltr_tainted[slot])
self.ltr_tainted[slot] = true; .filter_map(|(s, m)| m.map(|w| (s, w)))
.collect();
let plan = super::rfi::plan_slot_recovery(&view, first);
for (slot, tainted) in self.ltr_tainted.iter_mut().enumerate() {
if plan.tainted & (1 << slot) != 0 {
*tainted = true;
} }
} }
let mut best: Option<(usize, i64)> = None; match plan.anchor {
for (slot, marked) in self.ltr_slots.iter().enumerate() {
if self.ltr_tainted[slot] {
continue; // still in the DPB, but encoded inside the corrupt window
}
if let Some(idx) = *marked {
if idx < first && best.is_none_or(|(_, b)| idx > b) {
best = Some((slot, idx));
}
}
}
match best {
Some((slot, ltr_frame)) => { Some((slot, ltr_frame)) => {
self.pending_force = Some(slot); self.pending_force = Some(slot);
tracing::info!( tracing::info!(
+14
View File
@@ -1508,6 +1508,20 @@ mod nvenc_status;
#[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))] #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))]
#[path = "enc/nvenc_core.rs"] #[path = "enc/nvenc_core.rs"]
mod nvenc_core; mod nvenc_core;
// The slot-family RFI recovery policy (WP7.2) shared by native AMF, native QSV and Vulkan Video —
// the taint sweep + pre-loss anchor pick extracted from three hand-copies that had already
// diverged once (the fecbec2d sweep predates the Vulkan backend's carve-out and was never ported
// to it until a later fix). Policy only: every mechanism (LTR bitfield, RefListCtrl, DPB slot
// table) stays in its backend. cfg = the union of the callers': amf.rs is featureless on Windows,
// vulkan_video needs `vulkan-encode` on Linux — and each ITEM inside is live under the module's
// whole cfg because `plan_slot_recovery` delegates to `pick_anchor` (dead_code is an item lint;
// module-granular reasoning is how a Windows-dead helper reaches main).
#[cfg(any(
target_os = "windows",
all(target_os = "linux", feature = "vulkan-encode")
))]
#[path = "enc/rfi.rs"]
mod rfi;
// Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux // Shared libavcodec glue (`pixel_to_av`, swscale consts) for the three libav backends — Linux
// NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2). // NVENC + VAAPI and Windows AMF/QSV — so the byte-identical pieces live once (plan §2.2, Tier 2).
#[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))] #[cfg(any(target_os = "linux", all(target_os = "windows", feature = "amf-qsv")))]