diff --git a/crates/pf-encode/src/enc/linux/vulkan_video.rs b/crates/pf-encode/src/enc/linux/vulkan_video.rs index 6a541522..676460fa 100644 --- a/crates/pf-encode/src/enc/linux/vulkan_video.rs +++ b/crates/pf-encode/src/enc/linux/vulkan_video.rs @@ -363,17 +363,15 @@ impl Default for BsPtr { } } -/// Newest resident DPB slot whose wire index is strictly older than the loss — the clean anchor. -fn pick_recovery_slot(slot_wire: &[i64], loss_first: i64) -> Option { - let mut best: Option = None; - let mut best_wire = -1i64; - for (i, &w) in slot_wire.iter().enumerate() { - if w >= 0 && w < loss_first && w > best_wire { - best = Some(i); - best_wire = w; - } - } - best +/// The trusted-reference view of `slot_wire` for the shared RFI policy ([`crate::rfi`]): resident +/// slots only — `-1` marks empty *or distrusted* (blanked by an earlier loss's taint sweep), and +/// excluding it here is exact because the loss start is validity-gated non-negative. +fn trusted_refs(slot_wire: &[i64]) -> Vec<(usize, i64)> { + slot_wire + .iter() + .enumerate() + .filter_map(|(s, &w)| (w >= 0).then_some((s, w))) + .collect() } /// 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 { /// 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 /// `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 /// 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; if let Some(lf) = self.pending_loss.take() { if !is_idr { - match pick_recovery_slot(&self.slot_wire, lf) { - Some(s) => { + // Pick-only re-run of the shared policy: the taint sweep already happened in + // `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; recovery = true; tracing::debug!( @@ -3705,29 +3707,31 @@ impl Encoder for VulkanVideoEncoder { if first_frame < 0 || first_frame > last_frame { return false; } - // Taint sweep BEFORE picking the anchor (the fecbec2d fix AMF and QSV got; this backend was - // carved out one commit later and never received it). "Resident and older than THIS loss" is - // not the same as "the client decoded it": after an earlier loss [a,b] was recovered at wire - // r, everything in [a, r-1] is undecodable at the client — the lost frames plus every frame - // that predicted through the gap. Those wires stay valid anchor candidates here until the - // 8-slot ring rolls them out, so a LATER loss can anchor on one and ship corruption tagged - // `recovery_anchor` — which is the client's definitive re-anchor signal (reanchor.rs), so it - // lifts the post-loss freeze onto a picture built from a reference it never had. + // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision + // shared with AMF and QSV — the fecbec2d sweep reached those two a commit before this + // backend was carved out, and the hand-copy here shipped without it). Why the sweep: + // "resident and older than THIS loss" is not the same as "the client decoded it" — after + // an earlier loss [a,b] was recovered at wire r, everything in [a, r-1] is undecodable at + // the client, yet those wires stay anchor candidates until the 8-slot ring rolls them + // out, so a LATER loss could anchor on one and ship corruption tagged `recovery_anchor` — + // the client's definitive re-anchor signal (reanchor.rs). // - // Blank `slot_wire` ONLY. `slot_poc` must keep naming every physically-resident DPB picture - // for `build_h265_rps_s0`, or a conforming decoder evicts them and the anchor references a - // picture the client already dropped. `slot_wire` is the RFI/loss domain; `slot_poc` is the - // reference-delta domain. `prev_slot` and the normal P-frame path are indices, not wires, so - // ordinary prediction is unaffected. - for w in self.slot_wire.iter_mut() { - if *w >= first_frame { + // This backend's mechanism: distrust = blank `slot_wire` ONLY. `slot_poc` must keep + // naming every physically-resident DPB picture for `build_h265_rps_s0`, or a conforming + // decoder evicts them and the anchor references a picture the client already dropped. + // `slot_wire` is the RFI/loss domain; `slot_poc` is the reference-delta domain. + // `prev_slot` and the normal P-frame path are indices, not wires, so ordinary prediction + // is unaffected. + 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; } } // 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 // `qsv_live_ltr_rfi_taint_sweep_declines`.) - match pick_recovery_slot(&self.slot_wire, first_frame) { + match plan.anchor { Some(_) => { self.pending_loss = Some(first_frame); true @@ -3736,6 +3740,10 @@ impl Encoder for VulkanVideoEncoder { // 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 // 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!( first_frame, last_frame, @@ -4855,80 +4863,10 @@ unsafe fn build_parameters_av1( #[cfg(test)] 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 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 /// setup slot's dying occupant is not, and `used_by_curr_pic` marks exactly the real reference. #[test] diff --git a/crates/pf-encode/src/enc/rfi.rs b/crates/pf-encode/src/enc/rfi.rs new file mode 100644 index 00000000..214a3543 --- /dev/null +++ b/crates/pf-encode/src/enc/rfi.rs @@ -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); + } +} diff --git a/crates/pf-encode/src/enc/windows/amf.rs b/crates/pf-encode/src/enc/windows/amf.rs index 9def7d26..4305fea9 100644 --- a/crates/pf-encode/src/enc/windows/amf.rs +++ b/crates/pf-encode/src/enc/windows/amf.rs @@ -2373,31 +2373,27 @@ impl Encoder for AmfEncoder { if !self.ltr_active || first < 0 || first > last { return false; } - // Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was - // encoded inside the client's corrupt window — the client either never received it or - // decoded it against a broken reference chain. Serving it as "known-good" on a LATER - // loss ships corruption as the recovery anchor (and every subsequent mark re-samples - // it). Dropped slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. - for marked in self.ltr_slots.iter_mut() { - if marked.is_some_and(|idx| idx >= first) { + // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision + // shared with QSV and Vulkan Video); this backend's mechanism is: distrust = clear the + // mirror slot (dropped slots stay dropped; the cadence re-marks a clean frame within + // ~1/4 s). `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 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; } } - // Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the - // 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 { + match plan.anchor { Some((slot, ltr_frame)) => { // Queue the force for the next submit; that frame ships tagged `recovery_anchor`. self.pending_force = Some(slot); diff --git a/crates/pf-encode/src/enc/windows/qsv.rs b/crates/pf-encode/src/enc/windows/qsv.rs index aa23defe..fd12af75 100644 --- a/crates/pf-encode/src/enc/windows/qsv.rs +++ b/crates/pf-encode/src/enc/windows/qsv.rs @@ -1416,36 +1416,30 @@ impl Encoder for QsvEncoder { if !self.ltr_active || first < 0 || first > last { return false; } - // Taint sweep BEFORE picking the anchor: an LTR marked at-or-after the loss start was - // encoded inside the client's corrupt window — the client either never received it or - // decoded it against a broken reference chain. Serving it as "known-good" on a LATER - // loss ships corruption as the recovery anchor, and every subsequent mark re-samples - // the soup — the sustained-loss field failure where the picture never healed. Dropped - // slots stay dropped; the cadence re-marks a clean frame within ~1/4 s. - // - // Mark tainted rather than clearing: `ltr_slots` mirrors the HARDWARE DPB, and nulling an - // entry issues no VPL call — the frame stays marked long-term in the encoder. Clearing it - // made the rejection list below (which iterates the post-sweep mirror and only 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 is the modal - // one, and it was the broken one. - for (slot, marked) in self.ltr_slots.iter().enumerate() { - if marked.is_some_and(|idx| idx >= first) { - self.ltr_tainted[slot] = true; + // The taint-sweep + anchor-pick POLICY lives in `rfi::plan_slot_recovery` (one decision + // shared with AMF and Vulkan Video). This backend's mechanism: distrust is a 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. Clearing it made the rejection list below (which iterates the mirror and only + // 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 + // is the modal one, and it was the broken one. The `!ltr_tainted` view filter below is + // what persists that distrust across loss events (this call's taints are excluded from + // this call's anchor by the policy itself). + let view: Vec<(usize, i64)> = self + .ltr_slots + .iter() + .enumerate() + .filter(|&(slot, _)| !self.ltr_tainted[slot]) + .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; - 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 { + match plan.anchor { Some((slot, ltr_frame)) => { self.pending_force = Some(slot); tracing::info!( diff --git a/crates/pf-encode/src/lib.rs b/crates/pf-encode/src/lib.rs index a2b7d6d9..e843b8f4 100644 --- a/crates/pf-encode/src/lib.rs +++ b/crates/pf-encode/src/lib.rs @@ -1508,6 +1508,20 @@ mod nvenc_status; #[cfg(all(any(target_os = "linux", target_os = "windows"), feature = "nvenc"))] #[path = "enc/nvenc_core.rs"] 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 // 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")))]