Skip to main content

rusty_h264_decoder/
lib.rs

1//! Pure-Rust H.264 decoder — Constrained Baseline **+ B-slices + most of High
2//! profile**, CAVLC and CABAC.
3//!
4//! Validated **bit-exact against Cisco's `h264dec`** on 35 of 35 clean streams
5//! from openh264's conformance corpus; the CABAC paths were brought up
6//! symbol-by-symbol against an instrumented openh264 oracle and are gated
7//! **pixel-exact vs ffmpeg**. The reconstruction path is shared with the encoder
8//! (via `rusty_h264-common`), so the two halves agree bit-for-bit by
9//! construction.
10//!
11//! Decodes: full intra (`I_16x16`/`I_4x4`/`I_8x8`/`I_PCM`), inter
12//! (`P_Skip`/16×16/16×8/8×16/`P_8x8`) with quarter-pel motion compensation,
13//! B-slices (temporal + spatial direct, implicit + explicit weighted
14//! prediction), the 8×8 transform and 8×8 intra prediction, scaling matrices,
15//! in-loop deblocking, and a multi-reference DPB with POC reordering and MMCO.
16//! CABAC covers I, P and B slices incl. High-profile 8×8 residual (not yet: `I_PCM`).
17//!
18//! This crate is `#![forbid(unsafe_code)]` and is **fuzzed to never panic or
19//! hang** on malformed input — errors surface as [`DecodeError`].
20//!
21//! [`Decoder::decode_stream`] is the one-call entry point (frames in display
22//! order); [`Decoder::decode`] is the streaming form (one picture per access
23//! unit, in decode order — pair it with [`Decoder::last_poc`]).
24
25#![cfg_attr(not(feature = "std"), no_std)]
26// One thread without `std`: the frame-thread clients, the E2 worker seam and
27// the row-progress slots are compiled but never reached. Gating each of them
28// would scatter cfgs through the hot loop, so that arm allows the lint.
29#![cfg_attr(not(feature = "std"), allow(dead_code))]
30
31extern crate alloc;
32#[cfg(test)]
33extern crate std;
34
35// ---------------------------------------------------------------------------
36// `no_std` shims (see rusty_h264-common for the knob and once-cell shims this
37// crate reuses). Without `std` there is no environment, no stderr and no
38// second thread: a knob reads as unset, a print is a no-op, and the
39// cross-thread progress machinery below collapses to single-thread cells.
40// Defined before the modules so the macros are in textual scope.
41// ---------------------------------------------------------------------------
42
43#[cfg(not(feature = "std"))]
44macro_rules! eprintln {
45    ($($t:tt)*) => {{
46        let _ = ::core::format_args!($($t)*);
47    }};
48}
49#[cfg(not(feature = "std"))]
50#[allow(unused_macros)]
51macro_rules! println {
52    ($($t:tt)*) => {{
53        let _ = ::core::format_args!($($t)*);
54    }};
55}
56/// `thread_local!` without threads: each `NAME.with(|v| ..)` builds the value
57/// fresh (the encoder's polyfill, verbatim).
58#[cfg(not(feature = "std"))]
59macro_rules! thread_local {
60    () => {};
61    ($(#[$m:meta])* $vis:vis static $name:ident: $ty:ty = const { $init:expr }; $($rest:tt)*) => {
62        $(#[$m])* #[allow(non_camel_case_types)] $vis struct $name;
63        impl $name {
64            #[allow(dead_code)]
65            pub fn with<R>(&self, f: impl FnOnce(&$ty) -> R) -> R {
66                let v: $ty = $init;
67                f(&v)
68            }
69        }
70        thread_local!($($rest)*);
71    };
72    ($(#[$m:meta])* $vis:vis static $name:ident: $ty:ty = $init:expr; $($rest:tt)*) => {
73        $(#[$m])* #[allow(non_camel_case_types)] $vis struct $name;
74        impl $name {
75            #[allow(dead_code)]
76            pub fn with<R>(&self, f: impl FnOnce(&$ty) -> R) -> R {
77                let v: $ty = $init;
78                f(&v)
79            }
80        }
81        thread_local!($($rest)*);
82    };
83}
84
85/// The synchronisation vocabulary the frame-MT progress machinery uses, on
86/// both sides of the ladder. With `std` these are the real `std::sync` types.
87/// Without it there is exactly one thread, so a lock is a `RefCell` (a
88/// re-entrant borrow would be a bug either way and panics the same), a
89/// condition variable never has anyone to wait for, and the worker channels
90/// are placeholder types that are never constructed — the EDC worker and the
91/// frame pool live behind `std`, so nothing sends.
92pub(crate) mod sync {
93    #[cfg(not(feature = "std"))]
94    pub use core::cell::OnceCell as Once;
95    /// The write-once cell for a reference frame's frozen planes: the `std`
96    /// `OnceLock` (frame threads share reference frames), a plain `OnceCell`
97    /// without it (one thread, nothing to be `Sync` for). Both have the
98    /// `take(&mut self)` the recycling path uses; the common crate's shim is
99    /// for statics and has not.
100    #[cfg(feature = "std")]
101    pub use std::sync::OnceLock as Once;
102    #[cfg(feature = "std")]
103    pub use std::sync::{mpsc, Arc, Condvar, Mutex, RwLock, RwLockReadGuard};
104    #[cfg(feature = "std")]
105    pub fn yield_now() {
106        std::thread::yield_now();
107    }
108
109    #[cfg(not(feature = "std"))]
110    pub use alloc::sync::Arc;
111    #[cfg(not(feature = "std"))]
112    pub fn yield_now() {
113        core::hint::spin_loop();
114    }
115    #[cfg(not(feature = "std"))]
116    pub use single::*;
117
118    #[cfg(not(feature = "std"))]
119    mod single {
120        use core::cell::{Ref, RefCell, RefMut};
121
122        #[derive(Debug, Default)]
123        pub struct RwLock<T>(RefCell<T>);
124        pub type RwLockReadGuard<'a, T> = Ref<'a, T>;
125        impl<T> RwLock<T> {
126            pub const fn new(v: T) -> Self {
127                RwLock(RefCell::new(v))
128            }
129            pub fn read(&self) -> Result<Ref<'_, T>, ()> {
130                Ok(self.0.borrow())
131            }
132            pub fn write(&self) -> Result<RefMut<'_, T>, ()> {
133                Ok(self.0.borrow_mut())
134            }
135        }
136
137        #[derive(Debug, Default)]
138        pub struct Mutex<T>(RefCell<T>);
139        impl<T> Mutex<T> {
140            pub const fn new(v: T) -> Self {
141                Mutex(RefCell::new(v))
142            }
143            pub fn lock(&self) -> Result<RefMut<'_, T>, ()> {
144                Ok(self.0.borrow_mut())
145            }
146        }
147
148        #[derive(Debug, Default)]
149        pub struct Condvar;
150        impl Condvar {
151            pub const fn new() -> Self {
152                Condvar
153            }
154            pub fn wait<'a, T>(&self, g: RefMut<'a, T>) -> Result<RefMut<'a, T>, ()> {
155                Ok(g)
156            }
157            pub fn notify_all(&self) {}
158            pub fn notify_one(&self) {}
159        }
160
161        /// Channel placeholders: never constructed without `std` (the only
162        /// constructors sit inside the `std`-gated worker tails), so their
163        /// methods are unreachable by construction.
164        pub mod mpsc {
165            use core::marker::PhantomData;
166            pub struct SyncSender<T>(PhantomData<T>);
167            pub struct Sender<T>(PhantomData<T>);
168            pub struct Receiver<T>(PhantomData<T>);
169            pub struct SendError<T>(pub T);
170            #[derive(Debug)]
171            pub struct RecvError;
172            impl<T> core::fmt::Debug for SendError<T> {
173                fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
174                    f.write_str("SendError")
175                }
176            }
177            impl<T> SyncSender<T> {
178                pub fn send(&self, _t: T) -> Result<(), SendError<T>> {
179                    unreachable!("no worker thread without std")
180                }
181            }
182            impl<T> Sender<T> {
183                pub fn send(&self, _t: T) -> Result<(), SendError<T>> {
184                    unreachable!("no worker thread without std")
185                }
186            }
187            impl<T> Receiver<T> {
188                pub fn recv(&self) -> Result<T, RecvError> {
189                    unreachable!("no worker thread without std")
190                }
191            }
192        }
193    }
194}
195
196mod cabac;
197/// Profile-only re-export of the CABAC bin census for benchmarking harnesses.
198#[cfg(feature = "profile")]
199pub use cabac::bin_census;
200#[cfg(feature = "std")]
201mod frame_mt;
202mod mb16;
203mod params;
204
205pub use mb16::MvField;
206#[cfg(feature = "std")]
207pub use mb16::MV_DUMP;
208pub use params::{Pps, Sps};
209
210/// Frame-MT worker count (`RS_H264_FRAME_THREADS`); one thread without `std`.
211#[cfg(feature = "std")]
212pub(crate) fn frame_threads() -> usize {
213    frame_mt::frame_threads()
214}
215/// Frame-MT worker count; one thread without `std`.
216#[cfg(not(feature = "std"))]
217pub(crate) fn frame_threads() -> usize {
218    1
219}
220
221/// Frame-MT Phase B row publishing; never without `std` (one thread).
222#[cfg(feature = "std")]
223pub(crate) fn row_publish_on() -> bool {
224    frame_mt::row_publish_on()
225}
226/// Frame-MT Phase B row publishing; never without `std` (one thread).
227#[cfg(not(feature = "std"))]
228pub(crate) fn row_publish_on() -> bool {
229    false
230}
231/// Frame-MT Phase B row-progress waits; never without `std` (one thread).
232#[cfg(feature = "std")]
233pub(crate) fn row_progress_on() -> bool {
234    frame_mt::row_progress_on()
235}
236/// Frame-MT Phase B row-progress waits; never without `std` (one thread).
237#[cfg(not(feature = "std"))]
238pub(crate) fn row_progress_on() -> bool {
239    false
240}
241
242/// Print the E2 worker-seam counters (D7) if `RS_H264_EDC_STATS` is set.
243pub fn edc_stats_report() {
244    mb16::edcstat::report();
245    rusty_h264_common::deblock::filtstat::report();
246}
247
248/// Test-only re-export of the CABAC arithmetic *decoder* so the encoder crate can
249/// round-trip-validate its CABAC *encoder* against the exact reference engine.
250#[doc(hidden)]
251/// MEASUREMENT KNOB — `RFF_ABL_DEBLOCK=1` skips the loop filter so it can be
252/// priced by ablation on the UNINSTRUMENTED binary. Read once; inert when unset.
253fn abl_deblock() -> bool {
254    // ROUTED AT BUILD TIME, like the rest of the knob inventory: an ablation arm
255    // is only reachable under `--features knobs`, and leaving it runtime keeps the
256    // skip-the-filter branch live in the shipping decoder.
257    #[cfg(not(feature = "knobs"))]
258    {
259        return false;
260    }
261    #[cfg(feature = "knobs")]
262    {
263        static ON: rusty_h264_common::once::OnceLock<bool> =
264            rusty_h264_common::once::OnceLock::new();
265        *ON.get_or_init(|| rusty_h264_common::knob("RFF_ABL_DEBLOCK").map_or(false, |v| v != "0"))
266    }
267}
268
269pub mod cabac_test {
270    pub use crate::cabac::Cabac;
271    pub use crate::mb16::b_inter_shape;
272    pub use crate::mb16::parse_cbp_cabac;
273    pub use crate::mb16::parse_mb_qp_delta_cabac;
274    pub use crate::mb16::parse_mb_type_b;
275    pub use crate::mb16::parse_ref_idx_cabac;
276}
277
278#[allow(unused_imports)]
279use alloc::borrow::ToOwned;
280#[allow(unused_imports)]
281use alloc::boxed::Box;
282#[allow(unused_imports)]
283use alloc::format;
284#[allow(unused_imports)]
285use alloc::string::{String, ToString};
286#[allow(unused_imports)]
287use alloc::vec;
288#[allow(unused_imports)]
289use alloc::vec::Vec;
290use mb16::{FrameDecoder, GridPool, WeightTable};
291use rusty_h264_common::bit_reader::OutOfData;
292use rusty_h264_common::nal::{emulation_unprevent, split_annex_b};
293use rusty_h264_common::{BitReader, NalUnitType, YuvFrame};
294
295/// Decode errors.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum DecodeError {
298    /// Bitstream ended unexpectedly.
299    Truncated,
300    /// A required parameter set was missing before a slice.
301    MissingParameterSet,
302    /// A coding tool outside the implemented subset appeared in the stream.
303    Unsupported(&'static str),
304}
305
306impl From<OutOfData> for DecodeError {
307    fn from(_: OutOfData) -> Self {
308        DecodeError::Truncated
309    }
310}
311
312impl core::fmt::Display for DecodeError {
313    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
314        match self {
315            DecodeError::Truncated => f.write_str("bitstream truncated"),
316            DecodeError::MissingParameterSet => f.write_str("slice before SPS/PPS"),
317            DecodeError::Unsupported(s) => write!(f, "unsupported coding tool: {s}"),
318        }
319    }
320}
321
322impl core::error::Error for DecodeError {}
323
324/// A reference picture: deblocked reconstruction at coded resolution.
325/// Stored now (4a); read by motion compensation in 4b.
326/// Shared handle to a reference picture. The DPB and every per-slice reference
327/// list hold `Arc`s: list construction used to DEEP-CLONE each entry's planes +
328/// motion grids per slice (H-32 found ~600 KB+/slice of pure memcpy on B
329/// streams); an `Arc` clone is a refcount bump reading the same bytes, so the
330/// change is byte-identical by construction. `Arc::make_mut` covers the one
331/// mutation site (MMCO long-term marking).
332pub(crate) type Ref = crate::sync::Arc<RefFrame>;
333
334#[derive(Debug, Default)]
335#[allow(dead_code)]
336pub(crate) struct RefFrame {
337    /// EDGE-PADDED planes (openh264 `ExpandPicture`): built ONCE per reference
338    /// frame so motion compensation reads them in place — the per-MC-call
339    /// clamped-tile extraction (~400 B copied per call, ~100 MB/clip on real
340    /// streams) dies with this. Luma pad [`LPAD`], chroma pad [`CPAD`]; strides
341    /// via [`RefFrame::lstride`]/[`RefFrame::cstride`].
342    pub py: Vec<u8>,
343    pub pu: Vec<u8>,
344    pub pv: Vec<u8>,
345    pub cw: usize,
346    pub ch: usize,
347    /// Frame-MT Phase B: luma rows whose reconstruction (+row deblock when
348    /// enabled) is visible to other threads' MC. Phase A publishes `ch` (fully
349    /// ready) when the picture commits. `0` means not yet usable.
350    pub ready_rows: core::sync::atomic::AtomicUsize,
351    /// Phase B concurrent planes. When set, producers publish filtered rows into
352    /// these locks and consumers wait on [`Self::ready_rows`] then read here;
353    /// [`Self::py`]/[`Self::pu`]/[`Self::pv`] stay unused until commit copies
354    /// them out (serial / Phase A leave this `None` — zero lock tax on 1T).
355    pub live: Option<crate::sync::Arc<LivePlanes>>,
356    /// After picture finalize: lock-free planes for steady-state DPB MC.
357    /// Preferred over [`Self::live`] / empty [`Self::py`] once set.
358    pub frozen: crate::sync::Once<FrozenPlanes>,
359    /// `frame_num` of the picture, for PicNum-based reference-list reordering.
360    pub frame_num: u32,
361    /// `PicOrderCnt` of the picture, for B-slice reference-list ordering.
362    pub poc: i32,
363    /// Per-4×4-block List-0 motion field (motion vector + reference index, `-1`
364    /// for intra), and the block-grid width. Read as the *co-located* picture's
365    /// motion for B-slice direct prediction (`colZeroFlag`, temporal direct).
366    pub mv: Vec<(i32, i32)>,
367    pub ref_idx: Vec<i32>,
368    /// Per-4×4-block **List-1** motion. Needed because the co-located motion
369    /// derivation (spec §8.4.1.2.1) falls back to List-1 when the co-located block
370    /// has no List-0 prediction (`predFlagL0Col == 0`). A co-located picture only
371    /// contains L1-only blocks when it is itself a B picture — which is precisely
372    /// what b-pyramid produces, so this stayed unexercised until B-references
373    /// appeared.
374    pub mv1: Vec<(i32, i32)>,
375    pub ref_idx1: Vec<i32>,
376    /// Per-4×4-block POC of the List-0 picture each block referenced (`i32::MIN`
377    /// for intra). Used by temporal direct's `MapColToList0` (the co-located
378    /// reference index alone is meaningless in the current list).
379    pub ref_poc: Vec<i32>,
380    pub w4: usize,
381    /// Long-term reference state. Long-term refs sit after short-term ones in
382    /// `RefPicList0` (ordered by `long_term_idx` ascending) and survive the
383    /// sliding window until explicitly unmarked (spec §8.2.4).
384    pub long_term: bool,
385    pub long_term_idx: u32,
386}
387
388impl Clone for RefFrame {
389    fn clone(&self) -> Self {
390        use core::sync::atomic::Ordering::Relaxed;
391        let frozen = crate::sync::Once::new();
392        if let Some(p) = self.frozen.get() {
393            let _ = frozen.set(p.clone());
394        }
395        Self {
396            py: self.py.clone(),
397            pu: self.pu.clone(),
398            pv: self.pv.clone(),
399            cw: self.cw,
400            ch: self.ch,
401            ready_rows: core::sync::atomic::AtomicUsize::new(self.ready_rows.load(Relaxed)),
402            live: self.live.clone(),
403            frozen,
404            frame_num: self.frame_num,
405            poc: self.poc,
406            mv: self.mv.clone(),
407            ref_idx: self.ref_idx.clone(),
408            mv1: self.mv1.clone(),
409            ref_idx1: self.ref_idx1.clone(),
410            ref_poc: self.ref_poc.clone(),
411            w4: self.w4,
412            long_term: self.long_term,
413            long_term_idx: self.long_term_idx,
414        }
415    }
416}
417
418/// Luma / chroma pad of every [`RefFrame`] plane. Luma 16 serves MVs overshooting
419/// the picture by up to ~14 px in place (chroma: half that, matching); wilder MVs
420/// take `mc_*_padded`'s clamped-halo fallback — correct, just slower.
421pub(crate) const LPAD: usize = 16;
422pub(crate) const CPAD: usize = 8;
423
424thread_local! {
425    /// Per-thread MB-row MC watermark (`rows_needed_for_mb`). `usize::MAX` = unset.
426    static MC_ROW_NEED: core::cell::Cell<usize> = const { core::cell::Cell::new(usize::MAX) };
427}
428
429/// Lock-free padded planes after a Phase B progress slot is finalized.
430#[derive(Debug, Clone)]
431pub(crate) struct FrozenPlanes {
432    pub py: Vec<u8>,
433    pub pu: Vec<u8>,
434    pub pv: Vec<u8>,
435}
436
437/// Concurrent padded planes + metadata for frame-MT Phase B (row-progress).
438#[derive(Debug)]
439pub(crate) struct LivePlanes {
440    pub py: crate::sync::RwLock<Vec<u8>>,
441    pub pu: crate::sync::RwLock<Vec<u8>>,
442    pub pv: crate::sync::RwLock<Vec<u8>>,
443    /// Identity + coloc motion; written at submit (fn/poc) and finalize (mv).
444    pub meta: crate::sync::RwLock<LiveMeta>,
445    /// Park consumers until [`RefFrame::ready_rows`] advances (no spin tax).
446    pub wait: crate::sync::Mutex<()>,
447    pub cv: crate::sync::Condvar,
448}
449
450#[derive(Debug, Clone, Default)]
451pub(crate) struct LiveMeta {
452    pub frame_num: u32,
453    pub poc: i32,
454    pub long_term: bool,
455    pub long_term_idx: u32,
456    pub mv: Vec<(i32, i32)>,
457    pub ref_idx: Vec<i32>,
458    pub mv1: Vec<(i32, i32)>,
459    pub ref_idx1: Vec<i32>,
460    pub ref_poc: Vec<i32>,
461    pub w4: usize,
462    /// True once finalize has published coloc motion (temporal direct may read).
463    pub motion_ready: bool,
464}
465
466/// Guard over a luma/chroma plane (borrowed or Phase B live lock).
467pub(crate) enum PlaneGuard<'a> {
468    Borrowed(&'a [u8]),
469    Locked(crate::sync::RwLockReadGuard<'a, Vec<u8>>),
470}
471
472impl core::ops::Deref for PlaneGuard<'_> {
473    type Target = [u8];
474    fn deref(&self) -> &[u8] {
475        match self {
476            Self::Borrowed(s) => s,
477            Self::Locked(g) => g.as_slice(),
478        }
479    }
480}
481
482impl RefFrame {
483    /// Conservative luma rows needed before MC of MB row `mb_y` (MV overshoot pad).
484    #[inline]
485    pub(crate) fn rows_needed_for_mb(mb_y: usize, ch: usize) -> usize {
486        ((mb_y + 1) * 16 + LPAD).min(ch.max(1))
487    }
488
489    /// Publish the current MB-row MC watermark for this thread (parse or EDC worker).
490    /// [`Self::luma_guard`] / chroma take `min(caller_need, hint)` so legacy
491    /// `guard(ch)` call sites still early-start correctly under Phase B.
492    #[inline]
493    pub(crate) fn set_mc_row_need(mb_y: usize, ch: usize) {
494        // 1T / Phase A: no in-flight watermarks. TLS write on every MC was a
495        // no-op consumer of the hint (guards still waited on ready_rows).
496        if !crate::row_progress_on() {
497            return;
498        }
499        MC_ROW_NEED.with(|c| c.set(Self::rows_needed_for_mb(mb_y, ch)));
500    }
501
502    #[inline]
503    fn effective_need(&self, need_rows: usize) -> usize {
504        let hint = MC_ROW_NEED.with(|c| c.get());
505        need_rows.min(hint).min(self.ch.max(1))
506    }
507
508    /// Luma plane for MC, waiting on Phase B row watermark when needed.
509    /// Prefers frozen / plain planes (no lock); only in-flight slots take `live`.
510    #[inline]
511    pub(crate) fn luma_guard(&self, need_rows: usize) -> PlaneGuard<'_> {
512        if self.live.is_none() {
513            // 1T / Phase A: planes are complete. Skip TLS + ready_rows.
514            if let Some(f) = self.frozen.get() {
515                return PlaneGuard::Borrowed(&f.py);
516            }
517            return PlaneGuard::Borrowed(&self.py);
518        }
519        let need = self.effective_need(need_rows);
520        self.wait_ready_rows(need);
521        if let Some(f) = self.frozen.get() {
522            return PlaneGuard::Borrowed(&f.py);
523        }
524        if !self.py.is_empty() {
525            return PlaneGuard::Borrowed(&self.py);
526        }
527        if let Some(live) = &self.live {
528            PlaneGuard::Locked(live.py.read().unwrap())
529        } else {
530            PlaneGuard::Borrowed(&self.py)
531        }
532    }
533
534    #[inline]
535    pub(crate) fn chroma_guard(&self, plane: usize, need_rows: usize) -> PlaneGuard<'_> {
536        if self.live.is_none() {
537            if let Some(f) = self.frozen.get() {
538                return if plane == 0 {
539                    PlaneGuard::Borrowed(&f.pu)
540                } else {
541                    PlaneGuard::Borrowed(&f.pv)
542                };
543            }
544            return if plane == 0 {
545                PlaneGuard::Borrowed(&self.pu)
546            } else {
547                PlaneGuard::Borrowed(&self.pv)
548            };
549        }
550        let need = self.effective_need(need_rows);
551        self.wait_ready_rows(need);
552        if let Some(f) = self.frozen.get() {
553            return if plane == 0 {
554                PlaneGuard::Borrowed(&f.pu)
555            } else {
556                PlaneGuard::Borrowed(&f.pv)
557            };
558        }
559        if !self.py.is_empty() {
560            return if plane == 0 {
561                PlaneGuard::Borrowed(&self.pu)
562            } else {
563                PlaneGuard::Borrowed(&self.pv)
564            };
565        }
566        if let Some(live) = &self.live {
567            if plane == 0 {
568                PlaneGuard::Locked(live.pu.read().unwrap())
569            } else {
570                PlaneGuard::Locked(live.pv.read().unwrap())
571            }
572        } else if plane == 0 {
573            PlaneGuard::Borrowed(&self.pu)
574        } else {
575            PlaneGuard::Borrowed(&self.pv)
576        }
577    }
578    pub(crate) fn new_progress_slot(cw: usize, ch: usize, b_possible: bool, mb_w: usize) -> Ref {
579        let (lpw, lph) = (cw + 2 * LPAD, ch + 2 * LPAD);
580        let (cpw, cph) = (cw / 2 + 2 * CPAD, ch / 2 + 2 * CPAD);
581        let w4 = if b_possible { mb_w * 4 } else { 0 };
582        let n4 = if b_possible {
583            mb_w * 4 * (ch / 16) * 4
584        } else {
585            0
586        };
587        // Fat live planes only when strip-publishing; otherwise meta+CV only
588        // (MC parks until finalize freezes lock-free planes).
589        let (py, pu, pv) = if crate::row_publish_on() {
590            (vec![0; lpw * lph], vec![0; cpw * cph], vec![0; cpw * cph])
591        } else {
592            (Vec::new(), Vec::new(), Vec::new())
593        };
594        crate::sync::Arc::new(RefFrame {
595            py: Vec::new(),
596            pu: Vec::new(),
597            pv: Vec::new(),
598            cw,
599            ch,
600            ready_rows: core::sync::atomic::AtomicUsize::new(0),
601            live: Some(crate::sync::Arc::new(LivePlanes {
602                py: crate::sync::RwLock::new(py),
603                pu: crate::sync::RwLock::new(pu),
604                pv: crate::sync::RwLock::new(pv),
605                meta: crate::sync::RwLock::new(LiveMeta {
606                    mv: vec![(0, 0); n4],
607                    ref_idx: vec![-1; n4],
608                    mv1: vec![(0, 0); n4],
609                    ref_idx1: vec![-1; n4],
610                    ref_poc: vec![i32::MIN; n4],
611                    w4,
612                    ..LiveMeta::default()
613                }),
614                wait: crate::sync::Mutex::new(()),
615                cv: crate::sync::Condvar::new(),
616            })),
617            frozen: crate::sync::Once::new(),
618            frame_num: 0,
619            poc: 0,
620            mv: vec![(0, 0); n4],
621            ref_idx: vec![-1; n4],
622            mv1: vec![(0, 0); n4],
623            ref_idx1: vec![-1; n4],
624            ref_poc: vec![i32::MIN; n4],
625            w4,
626            long_term: false,
627            long_term_idx: 0,
628        })
629    }
630
631    /// Set identity while the progress Arc is still unique (submit thread).
632    pub(crate) fn init_progress_identity(slot: &mut Ref, frame_num: u32, poc: i32) {
633        if let Some(s) = crate::sync::Arc::get_mut(slot) {
634            s.frame_num = frame_num;
635            s.poc = poc;
636            if let Some(live) = &s.live {
637                let mut m = live.meta.write().unwrap();
638                m.frame_num = frame_num;
639                m.poc = poc;
640            }
641        }
642    }
643
644    #[inline]
645    pub(crate) fn fn_num(&self) -> u32 {
646        if let Some(live) = &self.live {
647            live.meta.read().unwrap().frame_num
648        } else {
649            self.frame_num
650        }
651    }
652
653    #[inline]
654    pub(crate) fn pic_poc(&self) -> i32 {
655        if let Some(live) = &self.live {
656            live.meta.read().unwrap().poc
657        } else {
658            self.poc
659        }
660    }
661
662    #[inline]
663    pub(crate) fn is_long_term(&self) -> bool {
664        if let Some(live) = &self.live {
665            live.meta.read().unwrap().long_term
666        } else {
667            self.long_term
668        }
669    }
670
671    #[inline]
672    pub(crate) fn lt_idx(&self) -> u32 {
673        if let Some(live) = &self.live {
674            live.meta.read().unwrap().long_term_idx
675        } else {
676            self.long_term_idx
677        }
678    }
679
680    pub(crate) fn set_long_term_marks(&self, long_term: bool, idx: u32) {
681        if let Some(live) = &self.live {
682            let mut m = live.meta.write().unwrap();
683            m.long_term = long_term;
684            m.long_term_idx = idx;
685        }
686    }
687
688    pub(crate) fn set_frame_num_live(&self, frame_num: u32) {
689        if let Some(live) = &self.live {
690            live.meta.write().unwrap().frame_num = frame_num;
691        }
692    }
693
694    /// Wait until coloc motion is published (picture fully finalized).
695    pub(crate) fn wait_motion_ready(&self) {
696        if self.live.is_none() {
697            return;
698        }
699        if let Some(live) = &self.live {
700            if live.meta.read().unwrap().motion_ready {
701                return;
702            }
703        }
704        if let Some(live) = &self.live {
705            let mut g = live.wait.lock().unwrap();
706            while !live.meta.read().unwrap().motion_ready {
707                g = live.cv.wait(g).unwrap();
708            }
709        }
710    }
711
712    /// Mark this reference fully ready (Phase A commit / serial path).
713    #[inline]
714    pub fn mark_fully_ready(&self) {
715        if let Some(live) = &self.live {
716            let _g = live.wait.lock().unwrap();
717            self.ready_rows
718                .store(self.ch, core::sync::atomic::Ordering::Release);
719            live.cv.notify_all();
720        } else {
721            self.ready_rows
722                .store(self.ch, core::sync::atomic::Ordering::Release);
723        }
724    }
725
726    /// Frame-MT Phase B: publish that luma rows `[0, rows)` are MC-safe.
727    #[inline]
728    pub fn publish_ready_rows(&self, rows: usize) {
729        let r = rows.min(self.ch);
730        if let Some(live) = &self.live {
731            let _g = live.wait.lock().unwrap();
732            let prev = self
733                .ready_rows
734                .fetch_max(r, core::sync::atomic::Ordering::Release);
735            if r > prev {
736                live.cv.notify_all();
737            }
738        } else {
739            let _ = self
740                .ready_rows
741                .fetch_max(r, core::sync::atomic::Ordering::Release);
742        }
743    }
744
745    /// Block until at least `rows` luma rows are ready (Phase B). Phase A refs
746    /// are published fully ready, so this returns immediately.
747    #[inline]
748    pub fn wait_ready_rows(&self, rows: usize) {
749        use core::sync::atomic::Ordering::Acquire;
750        let need = rows.min(self.ch);
751        if need == 0 || self.ready_rows.load(Acquire) >= need {
752            return;
753        }
754        if self.frozen.get().is_some() {
755            return;
756        }
757        if let Some(live) = &self.live {
758            let mut g = live.wait.lock().unwrap();
759            while self.ready_rows.load(Acquire) < need {
760                g = live.cv.wait(g).unwrap();
761            }
762        } else {
763            while self.ready_rows.load(Acquire) < need {
764                crate::sync::yield_now();
765            }
766        }
767    }
768
769    #[inline]
770    pub fn lstride(&self) -> usize {
771        self.cw + 2 * LPAD
772    }
773    #[inline]
774    pub fn cstride(&self) -> usize {
775        self.cw / 2 + 2 * CPAD
776    }
777    /// Rows in the PADDED luma plane. Four call sites recovered this as
778    /// `plane.len() / lstride()` -- an integer DIVIDE by a runtime value, per
779    /// P_Skip macroblock and per B-skip validity test, to recompute a constant
780    /// property of the frame. The plane is allocated `(cw + 2*LPAD) *
781    /// (ch + 2*LPAD)` and the stride is the first factor, so the row count is
782    /// the second one, exactly.
783    #[inline]
784    pub fn lrows(&self) -> usize {
785        self.ch + 2 * LPAD
786    }
787    /// Rows in either PADDED chroma plane (`(cw/2 + 2*CPAD) * (ch/2 + 2*CPAD)`).
788    #[inline]
789    pub fn crows(&self) -> usize {
790        self.ch / 2 + 2 * CPAD
791    }
792}
793
794/// A memory-management control operation (`dec_ref_pic_marking`, spec §7.4.3.3).
795#[derive(Clone, Copy)]
796enum Mmco {
797    /// 1: mark a short-term reference (by PicNum) as unused.
798    Unref(u32),
799    /// 2: mark a long-term reference (by LongTermPicNum) as unused.
800    UnrefLong(u32),
801    /// 3: assign a short-term reference (by PicNum) a LongTermFrameIdx.
802    AssignLong(u32, u32),
803    /// 4: drop long-term references with idx ≥ max_long_term_frame_idx_plus1.
804    MaxLong(u32),
805    /// 5: empty the DPB (and reset the current picture's frame_num to 0).
806    Reset,
807    /// 6: mark the current picture long-term with this LongTermFrameIdx.
808    CurrentLong(u32),
809}
810
811/// A picture being assembled from one or more slices (spec allows a picture to
812/// be split into multiple slices). Finalized — deblocked, output, and entered
813/// into the DPB — once all its macroblocks are decoded.
814/// GATE 1 content route (big-oppy-decoder §2): the 4-way cost tier the last
815/// completed picture classified into. ROUTER ONLY — nothing consumes it yet;
816/// consumers land per-route with their own gates.
817#[derive(Clone, Copy, PartialEq, Eq, Debug)]
818pub enum ContentRoute {
819    Light,
820    Mid,
821    DenseInter,
822    EntropyExtreme,
823}
824
825/// Per-tier route trees on the DEPLOYED counters (bits/MB, skip fraction,
826/// coded-MB fraction). Thresholds are calibrated on the deployed estimator
827/// itself (calibration table in docs/big-oppy-decoder-truthtable.xlsx) —
828/// never transplanted from an offline probe of a same-named signal.
829fn route_for(
830    cabac: bool,
831    t8x8: bool,
832    bits_per_mb: f64,
833    skip_frac: f64,
834    coded_frac: f64,
835) -> ContentRoute {
836    // Calibrated 2026-08-20 on the deployed counters over 68 streams
837    // (17 clips x 4 x264 tiers): LOCO-CV 17/17 cavlc, 15/17 main,
838    // 32/34 on the unified 8x8 signature (high+default) = 64/68.
839    let (root_coded, light_skip, extreme_bits) = if !cabac {
840        (0.6116, 0.6433, 406.4)
841    } else if t8x8 {
842        (0.4244, 0.6358, 331.7)
843    } else {
844        (0.3709, 0.6343, 315.2)
845    };
846    if coded_frac <= root_coded {
847        if skip_frac > light_skip {
848            ContentRoute::Light
849        } else {
850            ContentRoute::Mid
851        }
852    } else if bits_per_mb <= extreme_bits {
853        ContentRoute::DenseInter
854    } else {
855        ContentRoute::EntropyExtreme
856    }
857}
858
859struct PendingPic {
860    fd: mb16::FrameDecoder,
861    frame_num: u32,
862    poc: i32,
863    next_mb: usize,
864    total_mb: usize,
865    slice_count: u16,
866    deblock: bool,
867    filter_offset_a: i32,
868    filter_offset_b: i32,
869    crop_r: usize,
870    crop_b: usize,
871    max_refs: usize,
872    log2_max_frame_num: u32,
873    /// `false` for a non-reference picture (nal_ref_idc == 0): output it but do
874    /// not enter it into the DPB.
875    is_reference: bool,
876    idr_long_term: bool,
877    mmco_ops: Vec<Mmco>,
878    /// GATE 1 router inputs accumulated per picture.
879    route_bits: u64,
880    route_cabac: bool,
881    route_t8x8: bool,
882}
883
884/// Measurement knob: disable grid pooling, restoring per-picture allocation.
885fn no_pool() -> bool {
886    // ROUTED AT BUILD TIME. A measurement knob is only reachable under
887    // `--features knobs`; left runtime it costs an atomic load per picture AND
888    // keeps the un-pooled per-picture allocation path live in the binary.
889    #[cfg(not(feature = "knobs"))]
890    {
891        return false;
892    }
893    #[cfg(feature = "knobs")]
894    {
895        use core::sync::atomic::{AtomicU8, Ordering};
896        static ON: AtomicU8 = AtomicU8::new(0);
897        match ON.load(Ordering::Relaxed) {
898            0 => {
899                let v = rusty_h264_common::knob("RS_H264_NO_POOL").is_some_and(|v| v == "1");
900                ON.store(if v { 1 } else { 2 }, Ordering::Relaxed);
901                v
902            }
903            n => n == 1,
904        }
905    }
906}
907
908/// A Constrained Baseline H.264 decoder. Holds the most recent parameter sets
909/// and the previous decoded picture (the inter reference) across calls.
910#[derive(Default)]
911pub struct Decoder {
912    /// GATE 1 route of the most recently completed picture (router only).
913    last_route: Option<ContentRoute>,
914    /// EMA'd router signals (bits/MB, skip frac, coded frac). The thresholds
915    /// were calibrated on per-STREAM means; a per-picture read sits below the
916    /// root on P-pictures of boundary streams (shields) while I-pictures sit
917    /// far above — the EMA (alpha 1/8) reproduces the calibrated estimator at
918    /// steady state and adapts over ~8 pictures.
919    route_ema: Option<(f64, f64, f64)>,
920    /// Active parameter sets, keyed by id — a stream may carry several and switch
921    /// between them per slice (spec §7.3.2.1/.2).
922    // 11.11: Arc'd so the per-slice "clone to end the map borrow" is a
923    // refcount bump, not a struct copy (scaling lists included).
924    pub(crate) sps: alloc::collections::BTreeMap<u32, crate::sync::Arc<Sps>>,
925    pub(crate) pps: alloc::collections::BTreeMap<u32, crate::sync::Arc<Pps>>,
926    /// Decoded-picture buffer (most-recent first); `ref_idx` indexes into this.
927    pub(crate) refs: Vec<Ref>,
928    /// The picture currently being assembled from its slices, if any.
929    cur: Option<PendingPic>,
930    /// Picture-order-count state (spec §8.2.1). Tracks the previous reference
931    /// picture's MSB/LSB (type 0) and frame-num offset (types 1/2) so display
932    /// order can be recovered — needed once B-pictures (out-of-order) land.
933    pub(crate) poc: PocState,
934    /// `PicOrderCnt` of the most recently returned picture (display-order key).
935    pub(crate) last_poc: i32,
936    /// `frame_num` of the previous short-term reference picture, for detecting
937    /// gaps in `frame_num` (spec §8.2.5.2).
938    pub(crate) prev_ref_frame_num: u32,
939    /// Per-picture grid allocations, handed from the finished picture to the next
940    /// one instead of being freed and re-allocated. See `mb16::GridPool`.
941    grid_pool: GridPool,
942    /// 11.11: slice-header scratch (reorder lists + MMCO ops), recycled.
943    sc_reorder0: Vec<(u32, u32)>,
944    sc_reorder1: Vec<(u32, u32)>,
945    sc_mmco: Vec<Mmco>,
946    /// Recycled padded-plane buffers from evicted reference frames, drawn by
947    /// `as_reference_pooled`. Bounded (see `reclaim_retired`).
948    plane_pool: Vec<Vec<u8>>,
949    /// Reference frames evicted from the DPB whose planes have not been
950    /// reclaimed yet. Reclamation must wait until the evicting picture's
951    /// `FrameDecoder` is consumed — while it lives it still holds `Arc` clones
952    /// of its ref lists, so `Arc::try_unwrap` would fail at eviction time.
953    retired: Vec<Ref>,
954    /// Frame-MT: when set, finalize does not apply DPB marking; the new ref is
955    /// stashed in [`Self::detached_ref`] for the scheduler to commit in order.
956    pub(crate) detach_dpb: bool,
957    /// Detached reference as `Arc` (Phase B progress slot or freshly wrapped).
958    pub(crate) detached_ref: Option<Ref>,
959    /// Phase B: pre-installed progress Arc filled during decode / finalize.
960    pub(crate) progress_slot: Option<Ref>,
961    detached_mmco: Vec<Mmco>,
962    detached_frame_num: u32,
963    detached_log2_max_frame_num: u32,
964    detached_max_refs: usize,
965    detached_idr_long_term: bool,
966    /// Frame-MT Phase B: publish row watermarks while decoding.
967    pub(crate) frame_mt_row_progress: bool,
968}
969
970/// Running picture-order-count derivation state.
971#[derive(Clone, Default)]
972pub(crate) struct PocState {
973    prev_msb: i32,
974    prev_lsb: i32,
975    prev_frame_num: u32,
976    prev_frame_num_offset: i64,
977}
978
979impl PocState {
980    /// Derives `PicOrderCnt` for the current picture (spec §8.2.1) and advances
981    /// this state. Types 0 and 2 are exact; type 1 is approximated by frame-num
982    /// order (no B-stream in scope uses it).
983    pub(crate) fn compute_poc(
984        &mut self,
985        sps: &Sps,
986        is_idr: bool,
987        nal_ref_idc: u8,
988        frame_num: u32,
989        poc_lsb: u32,
990        delta_bottom: i32,
991    ) -> i32 {
992        match sps.pic_order_cnt_type {
993            0 => {
994                let max_lsb = 1i32 << sps.log2_max_pic_order_cnt_lsb;
995                let (prev_msb, prev_lsb) = if is_idr {
996                    (0, 0)
997                } else {
998                    (self.prev_msb, self.prev_lsb)
999                };
1000                let lsb = poc_lsb as i32;
1001                let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 {
1002                    prev_msb + max_lsb
1003                } else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 {
1004                    prev_msb - max_lsb
1005                } else {
1006                    prev_msb
1007                };
1008                let top = msb + lsb;
1009                let poc = top.min(top + delta_bottom);
1010                if nal_ref_idc != 0 {
1011                    self.prev_msb = msb;
1012                    self.prev_lsb = lsb;
1013                }
1014                poc
1015            }
1016            2 => {
1017                let max_fn = 1i64 << sps.log2_max_frame_num;
1018                let offset = if is_idr {
1019                    0
1020                } else if self.prev_frame_num > frame_num {
1021                    self.prev_frame_num_offset + max_fn
1022                } else {
1023                    self.prev_frame_num_offset
1024                };
1025                let poc = if is_idr {
1026                    0
1027                } else {
1028                    2 * (offset + frame_num as i64) - i64::from(nal_ref_idc == 0)
1029                };
1030                self.prev_frame_num_offset = offset;
1031                self.prev_frame_num = frame_num;
1032                poc as i32
1033            }
1034            _ => {
1035                self.prev_frame_num = frame_num;
1036                frame_num as i32 * 2
1037            }
1038        }
1039    }
1040}
1041
1042impl Decoder {
1043    /// Creates a decoder with no parameter sets yet.
1044    pub fn new() -> Self {
1045        Self::default()
1046    }
1047
1048    /// Decodes a complete Annex-B access unit, returning the reconstructed,
1049    /// cropped frame if the access unit contained a coded picture.
1050    pub fn decode(&mut self, annex_b: &[u8]) -> Result<Option<YuvFrame>, DecodeError> {
1051        let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Total);
1052        let mut frame = None;
1053        // The Annex-B scan and the RBSP unescape are each a FULL byte-wise pass over
1054        // the stream, and neither was timed — they landed in the unnamed residue that
1055        // the anatomy measured at 23-30% of decode outside the MB bodies.
1056        let nals = {
1057            let _s = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::DecNalSplit);
1058            split_annex_b(annex_b)
1059        };
1060        for nal in nals {
1061            if nal.is_empty() {
1062                continue;
1063            }
1064            let nal_type = NalUnitType::from_id(nal[0]);
1065            let rbsp = {
1066                let _s = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::DecRbsp);
1067                emulation_unprevent(&nal[1..])
1068            };
1069            match nal_type {
1070                NalUnitType::Sps => {
1071                    let s = Sps::parse(&rbsp)?;
1072                    self.sps
1073                        .insert(s.seq_parameter_set_id, crate::sync::Arc::new(s));
1074                }
1075                NalUnitType::Pps => {
1076                    let p = Pps::parse(&rbsp)?;
1077                    self.pps
1078                        .insert(p.pic_parameter_set_id, crate::sync::Arc::new(p));
1079                }
1080                NalUnitType::IdrSlice | NalUnitType::NonIdrSlice => {
1081                    let nal_ref_idc = (nal[0] >> 5) & 3;
1082                    let is_idr = nal_type == NalUnitType::IdrSlice;
1083                    if let Some(f) = self.decode_slice(&rbsp, is_idr, nal_ref_idc)? {
1084                        frame = Some(f);
1085                    }
1086                }
1087                _ => {} // SEI, AUD, etc. ignored
1088            }
1089        }
1090        Ok(frame)
1091    }
1092
1093    /// Decodes a complete Annex-B byte stream and returns every picture in
1094    /// **display order** (`PicOrderCnt` within each GOP; an IDR ends a GOP).
1095    ///
1096    /// This is the convenient whole-stream entry point — it handles access-unit
1097    /// splitting, multi-slice picture assembly, and B-picture reordering — versus
1098    /// the lower-level per-access-unit [`Decoder::decode`], which returns pictures
1099    /// in decode order.
1100    ///
1101    /// When `RS_H264_FRAME_THREADS` is set to N>1 (or the caller uses
1102    /// [`Decoder::decode_stream_threaded`]), pictures decode on a worker pool
1103    /// under a full-reference barrier (campaign #1 Phase A). Measure with
1104    /// `bench/pinmt.ps1` (WALL+CPU, multi-core mask) — not the 1T CPU race.
1105    pub fn decode_stream(&mut self, annex_b: &[u8]) -> Result<Vec<YuvFrame>, DecodeError> {
1106        #[cfg(feature = "std")]
1107        {
1108            let n = frame_mt::frame_threads();
1109            if n > 1 {
1110                return frame_mt::decode_stream_threaded(annex_b, n);
1111            }
1112        }
1113        self.decode_stream_serial(annex_b)
1114    }
1115
1116    /// Force frame-MT with an explicit worker count (0/1 = serial).
1117    #[cfg(feature = "std")]
1118    pub fn decode_stream_threaded(
1119        &mut self,
1120        annex_b: &[u8],
1121        threads: usize,
1122    ) -> Result<Vec<YuvFrame>, DecodeError> {
1123        if threads <= 1 {
1124            return self.decode_stream_serial(annex_b);
1125        }
1126        frame_mt::decode_stream_threaded(annex_b, threads)
1127    }
1128
1129    /// Frame-MT decode that invokes `sink` for each display-ordered frame
1130    /// (avoids retaining all YUV — use from `decode_bench` timed path).
1131    #[cfg(feature = "std")]
1132    pub fn decode_stream_threaded_sink(
1133        &mut self,
1134        annex_b: &[u8],
1135        threads: usize,
1136        sink: impl FnMut(YuvFrame),
1137    ) -> Result<usize, DecodeError> {
1138        if threads <= 1 {
1139            let frames = self.decode_stream_serial(annex_b)?;
1140            let n = frames.len();
1141            let mut sink = sink;
1142            for f in frames {
1143                sink(f);
1144            }
1145            return Ok(n);
1146        }
1147        frame_mt::decode_stream_threaded_sink(annex_b, threads, sink)
1148    }
1149
1150    fn decode_stream_serial(&mut self, annex_b: &[u8]) -> Result<Vec<YuvFrame>, DecodeError> {
1151        let mut out = Vec::new();
1152        let mut gop: Vec<(i32, YuvFrame)> = Vec::new();
1153        for au in split_access_units(annex_b) {
1154            if au_is_idr(au) {
1155                flush_gop(&mut gop, &mut out); // emit the prior GOP before the IDR
1156            }
1157            if let Some(frame) = self.decode(au)? {
1158                gop.push((self.last_poc, frame));
1159            }
1160        }
1161        flush_gop(&mut gop, &mut out);
1162        Ok(out)
1163    }
1164
1165    fn decode_slice(
1166        &mut self,
1167        rbsp: &[u8],
1168        is_idr: bool,
1169        nal_ref_idc: u8,
1170    ) -> Result<Option<YuvFrame>, DecodeError> {
1171        let mut r = BitReader::new(rbsp);
1172        // --- slice_header ---
1173        let first_mb_in_slice = r.read_ue()? as usize;
1174        let slice_type = r.read_ue()?;
1175        let is_p = matches!(slice_type, 0 | 5);
1176        let is_b = matches!(slice_type, 1 | 6);
1177        let is_i = matches!(slice_type, 2 | 7);
1178        if !is_p && !is_b && !is_i {
1179            return Err(DecodeError::Unsupported("SP/SI slices"));
1180        }
1181        // Resolve the parameter sets this slice references (by id).
1182        let pic_parameter_set_id = r.read_ue()?;
1183        let pps = self
1184            .pps
1185            .get(&pic_parameter_set_id)
1186            .cloned()
1187            .ok_or(DecodeError::MissingParameterSet)?;
1188        let sps = self
1189            .sps
1190            .get(&pps.seq_parameter_set_id)
1191            .cloned()
1192            .ok_or(DecodeError::MissingParameterSet)?;
1193        let sps = &sps;
1194        let pps = &pps;
1195        // CABAC (entropy_coding_mode_flag=1) has an entirely different slice-data parse
1196        // (docs/cabac-decode-plan.md). I-slice CABAC is being brought up; the CABAC MB
1197        // loop gates P/B until Phase 3. `cabac_init_idc` (P/B only) is read below.
1198        let cabac = pps.entropy_coding_mode_flag;
1199        let frame_num = r.read_bits(sps.log2_max_frame_num)?;
1200        if is_idr {
1201            let _idr_pic_id = r.read_ue()?;
1202        }
1203        // pic_order_cnt fields (spec §7.3.3). `field_pic_flag` is always 0
1204        // (frame_mbs_only). Captured to derive PicOrderCnt for display ordering.
1205        let mut poc_lsb = 0u32;
1206        let mut delta_poc_bottom = 0i32;
1207        if sps.pic_order_cnt_type == 0 {
1208            poc_lsb = r.read_bits(sps.log2_max_pic_order_cnt_lsb)?;
1209            if pps.bottom_field_pic_order_present {
1210                delta_poc_bottom = r.read_se()?;
1211            }
1212        } else if sps.pic_order_cnt_type == 1 && !sps.delta_pic_order_always_zero {
1213            let _delta_pic_order_cnt_0 = r.read_se()?;
1214            if pps.bottom_field_pic_order_present {
1215                let _delta_pic_order_cnt_1 = r.read_se()?;
1216            }
1217        }
1218        // PicOrderCnt is determined by the first slice of the picture; later
1219        // slices share it (and must not re-advance the POC state).
1220        let pic_poc = if first_mb_in_slice == 0 {
1221            self.compute_poc(
1222                sps,
1223                is_idr,
1224                nal_ref_idc,
1225                frame_num,
1226                poc_lsb,
1227                delta_poc_bottom,
1228            )
1229        } else {
1230            self.cur.as_ref().map_or(0, |p| p.poc)
1231        };
1232        // redundant_pic_cnt: a non-zero value marks a *redundant* coded picture
1233        // (an alternative representation of the primary picture). A primary
1234        // decoder discards it (spec §7.4.3, §8.2.5 note). Must be read here or the
1235        // rest of the slice header desyncs.
1236        if pps.redundant_pic_cnt_present_flag {
1237            let redundant_pic_cnt = r.read_ue()?;
1238            if redundant_pic_cnt != 0 {
1239                return Ok(None);
1240            }
1241        }
1242        if crate::mb16::dump_mb_on() {
1243            eprintln!(
1244                "SLICE fn={frame_num} poc={pic_poc} nal_ref_idc={nal_ref_idc} is_p={is_p} is_b={is_b} first_mb={first_mb_in_slice}"
1245            );
1246        }
1247        // B slices choose direct-mode derivation here (spec §7.3.3).
1248        let direct_spatial = if is_b { r.read_bit()? } else { true };
1249        let mut num_ref_idx_l0 = pps.num_ref_idx_l0_default as usize;
1250        let mut num_ref_idx_l1 = pps.num_ref_idx_l1_default as usize;
1251        let mut reorder_l0: Vec<(u32, u32)> = core::mem::take(&mut self.sc_reorder0);
1252        reorder_l0.clear();
1253        let mut reorder_l1: Vec<(u32, u32)> = core::mem::take(&mut self.sc_reorder1);
1254        reorder_l1.clear();
1255        if is_p || is_b {
1256            // num_ref_idx_active_override_flag
1257            if r.read_bit()? {
1258                num_ref_idx_l0 = (r.read_ue()? + 1) as usize;
1259                if is_b {
1260                    num_ref_idx_l1 = (r.read_ue()? + 1) as usize;
1261                }
1262            }
1263            // ref_pic_list_modification_flag_l0
1264            if r.read_bit()? {
1265                parse_ref_pic_list_modification(&mut r, &mut reorder_l0)?;
1266            }
1267            if is_b && r.read_bit()? {
1268                // ref_pic_list_modification_flag_l1
1269                parse_ref_pic_list_modification(&mut r, &mut reorder_l1)?;
1270            }
1271        }
1272        // Explicit weighted prediction carries a pred_weight_table() here. P
1273        // (weighted_pred) uses single-list weights; B explicit bipred (idc 1) is
1274        // not yet wired into the bi-pred averaging, so refuse that. Implicit
1275        // bipred (idc 2) carries no table.
1276        let weights = if is_p && pps.weighted_pred {
1277            Some(parse_pred_weight_table(&mut r, num_ref_idx_l0, 0, false)?)
1278        } else if is_b && pps.weighted_bipred_idc == 1 {
1279            return Err(DecodeError::Unsupported("explicit B weighted prediction"));
1280        } else {
1281            None
1282        };
1283        // dec_ref_pic_marking (spec §7.3.3.3) — present only for reference
1284        // pictures (nal_ref_idc != 0). Reading it for a non-reference slice would
1285        // desync the rest of the header.
1286        let mut idr_long_term = false;
1287        let mut mmco_ops: Vec<Mmco> = core::mem::take(&mut self.sc_mmco);
1288        mmco_ops.clear();
1289        if nal_ref_idc == 0 {
1290            // non-reference picture: no marking syntax
1291        } else if is_idr {
1292            let _no_output_of_prior_pics = r.read_bit()?;
1293            idr_long_term = r.read_bit()?; // long_term_reference_flag
1294        } else if r.read_bit()? {
1295            // adaptive_ref_pic_marking_mode_flag
1296            loop {
1297                let op = r.read_ue()?;
1298                match op {
1299                    0 => break,
1300                    1 => mmco_ops.push(Mmco::Unref(r.read_ue()?)),
1301                    2 => mmco_ops.push(Mmco::UnrefLong(r.read_ue()?)),
1302                    3 => {
1303                        let diff = r.read_ue()?;
1304                        let idx = r.read_ue()?;
1305                        mmco_ops.push(Mmco::AssignLong(diff, idx));
1306                    }
1307                    4 => mmco_ops.push(Mmco::MaxLong(r.read_ue()?)),
1308                    5 => mmco_ops.push(Mmco::Reset),
1309                    6 => mmco_ops.push(Mmco::CurrentLong(r.read_ue()?)),
1310                    _ => return Err(DecodeError::Unsupported("invalid MMCO")),
1311                }
1312                if mmco_ops.len() > 128 {
1313                    return Err(DecodeError::Truncated);
1314                }
1315            }
1316        }
1317        // cabac_init_idc (spec §7.3.3) — CABAC context-model preset, P/B slices only.
1318        // Spec range [0,2]; a larger (corrupt) value would index the 4-model context-init
1319        // table out of bounds, so reject it here.
1320        let cabac_init_idc = if cabac && !is_i {
1321            let v = r.read_ue()?;
1322            if v > 2 {
1323                return Err(DecodeError::Unsupported("invalid cabac_init_idc"));
1324            }
1325            v
1326        } else {
1327            0
1328        };
1329        let slice_qp_delta = r.read_se()?;
1330        // When deblocking_filter_control_present_flag is 0 the slice carries no
1331        // disable_deblocking_filter_idc and it is inferred 0 — i.e. the in-loop
1332        // filter is ON by default (spec §7.4.3). (Our own encoder always signals
1333        // the control explicitly, so this default was previously untested.)
1334        let mut deblock = true;
1335        let mut deblock_idc2 = false;
1336        let (mut filter_offset_a, mut filter_offset_b) = (0i32, 0i32);
1337        if pps.deblocking_filter_control_present_flag {
1338            let disable_deblocking_filter_idc = r.read_ue()?;
1339            // idc 1 = filter off; idc 0 = on; idc 2 = on, but this slice's MB
1340            // edges against OTHER slices are not filtered (bS forced 0 at the
1341            // crossing edges in derive_bs_row).
1342            deblock = disable_deblocking_filter_idc != 1;
1343            deblock_idc2 = disable_deblocking_filter_idc == 2;
1344            if disable_deblocking_filter_idc != 1 {
1345                // FilterOffset = slice_*_offset_div2 × 2 (spec §7.4.3).
1346                filter_offset_a = r.read_se()? * 2;
1347                filter_offset_b = r.read_se()? * 2;
1348            }
1349        }
1350        let slice_qp = (pps.pic_init_qp + slice_qp_delta).clamp(0, 51) as u8;
1351
1352        // Synthesize placeholder short-term references for any gap in frame_num
1353        // (spec §8.2.5.2) so the DPB / PicNum mapping stays correct.
1354        if first_mb_in_slice == 0 && !is_idr && sps.gaps_in_frame_num_allowed {
1355            self.insert_frame_num_gaps(
1356                frame_num,
1357                1u32 << sps.log2_max_frame_num,
1358                sps.max_num_ref_frames.max(1) as usize,
1359                sps.pic_width_in_mbs * 16,
1360                sps.pic_height_in_mbs * 16,
1361            );
1362        }
1363
1364        // Build the reference list(s) for this slice. P uses RefPicList0 only;
1365        // B uses RefPicList0 and RefPicList1 (POC-ordered).
1366        let max_fn = 1u32 << sps.log2_max_frame_num;
1367        let (ref_list0, ref_list1) = if is_b {
1368            build_ref_list_b(
1369                &self.refs,
1370                pic_poc,
1371                frame_num,
1372                max_fn,
1373                num_ref_idx_l0,
1374                num_ref_idx_l1,
1375                &reorder_l0,
1376                &reorder_l1,
1377            )?
1378        } else if is_p {
1379            (
1380                build_ref_list_p(&self.refs, frame_num, max_fn, num_ref_idx_l0, &reorder_l0)?,
1381                Vec::new(),
1382            )
1383        } else {
1384            (Vec::new(), Vec::new())
1385        };
1386        // Return the reorder scratch: capacity survives to the next slice.
1387        self.sc_reorder0 = reorder_l0;
1388        self.sc_reorder1 = reorder_l1;
1389        // --- picture assembly ---
1390        // first_mb_in_slice == 0 starts a new picture; otherwise this slice
1391        // continues the one in flight. An IDR clears the DPB at its first slice.
1392        if first_mb_in_slice == 0 {
1393            if is_idr {
1394                self.refs.clear();
1395            }
1396            // H-49: the CABAC macroblock loop never decodes `transform_size_8x8_flag`
1397            // — both reads of it sit on the CAVLC `BitReader`, and `decode_i8x8` only
1398            // accepts one. A PPS with `transform_8x8_mode_flag` set therefore desyncs
1399            // the arithmetic decoder within a few macroblocks, and the failure surfaces
1400            // as a bogus `CABAC I_PCM` far from its cause (the mb_type parse lands on
1401            // 25 out of garbage). Fail fast and accurately instead: a wrong error that
1402            // points at the wrong feature costs more than a missing feature does.
1403            // Removing this guard requires the CABAC 8×8 residual path — see H-49.
1404            // DecSetup was declared in the Stage enum but never actually scoped, so
1405            // the per-picture grid allocation had been invisible in every profile.
1406            let _g_setup = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::DecSetup);
1407            let mut fd = FrameDecoder::with_pool(
1408                sps.pic_width_in_mbs,
1409                sps.pic_height_in_mbs,
1410                slice_qp,
1411                pps.chroma_qp_index_offset,
1412                ref_list0,
1413                num_ref_idx_l0,
1414                pps.constrained_intra_pred_flag,
1415                pps.transform_8x8_mode_flag,
1416                sps.profile_idc != 66, // b_possible: Baseline/Constrained Baseline (66) forbid B
1417                // `RS_H264_NO_POOL=1` reproduces the pre-pool behaviour exactly (a
1418                // fresh allocation per picture) so the pool can be A/B'd paired on
1419                // one binary, with no rebuild between arms.
1420                if no_pool() {
1421                    GridPool::default()
1422                } else {
1423                    core::mem::take(&mut self.grid_pool)
1424                },
1425            );
1426            if is_b {
1427                fd.set_b_context(
1428                    ref_list1,
1429                    num_ref_idx_l1,
1430                    direct_spatial,
1431                    pic_poc,
1432                    pps.weighted_bipred_idc,
1433                    sps.direct_8x8_inference,
1434                );
1435            }
1436            if sps.has_scaling || pps.pic_scaling_matrix_present {
1437                let (s4, s8) = resolve_scaling(sps, pps);
1438                fd.set_scaling(s4, s8);
1439            }
1440            fd.set_transform_bypass(sps.transform_bypass);
1441            if let Some(w) = weights {
1442                fd.set_weights(w);
1443            }
1444            if let Some(slot) = self.progress_slot.clone() {
1445                fd.set_progress_slot(slot);
1446            }
1447            // A pending picture still here means the previous one never reached
1448            // total_mb and a new picture is now displacing it. That is a DECODER
1449            // desync, and dropping it silently is how the missing-B-slice-ref_idx
1450            // defect stayed hidden: the picture simply never entered the DPB, and
1451            // the failure surfaced hundreds of macroblocks later as "bitstream
1452            // truncated" from a reference-list modification asking for it. Refuse
1453            // to swallow it -- an incomplete picture must announce itself.
1454            if let Some(prev) = self.cur.take() {
1455                if prev.next_mb < prev.total_mb {
1456                    return Err(DecodeError::Truncated);
1457                }
1458            }
1459            self.cur = Some(PendingPic {
1460                fd,
1461                frame_num,
1462                poc: pic_poc,
1463                next_mb: 0,
1464                route_bits: 0,
1465                route_cabac: pps.entropy_coding_mode_flag,
1466                route_t8x8: pps.transform_8x8_mode_flag,
1467                total_mb: sps.pic_width_in_mbs * sps.pic_height_in_mbs,
1468                slice_count: 0,
1469                deblock,
1470                filter_offset_a,
1471                filter_offset_b,
1472                crop_r: sps.frame_crop_right as usize,
1473                crop_b: sps.frame_crop_bottom as usize,
1474                max_refs: sps.max_num_ref_frames.max(1) as usize,
1475                log2_max_frame_num: sps.log2_max_frame_num,
1476                is_reference: nal_ref_idc != 0,
1477                idr_long_term,
1478                mmco_ops,
1479            });
1480        } else {
1481            // Continuation slice: reset the per-slice QP + reference list.
1482            let Some(pic) = self.cur.as_mut() else {
1483                return Err(DecodeError::Unsupported(
1484                    "slice continues a missing picture",
1485                ));
1486            };
1487            pic.fd.begin_slice(slice_qp, ref_list0, num_ref_idx_l0);
1488            if is_b {
1489                pic.fd.set_b_context(
1490                    ref_list1,
1491                    num_ref_idx_l1,
1492                    direct_spatial,
1493                    pic.poc,
1494                    pps.weighted_bipred_idc,
1495                    sps.direct_8x8_inference,
1496                );
1497            }
1498            if sps.has_scaling || pps.pic_scaling_matrix_present {
1499                let (s4, s8) = resolve_scaling(sps, pps);
1500                pic.fd.set_scaling(s4, s8);
1501            }
1502            pic.fd.set_transform_bypass(sps.transform_bypass);
1503            if let Some(w) = weights {
1504                pic.fd.set_weights(w);
1505            }
1506            // Latest slice's marking/deblock parameters win at finalization.
1507            pic.deblock = deblock;
1508            pic.filter_offset_a = filter_offset_a;
1509            pic.filter_offset_b = filter_offset_b;
1510            pic.idr_long_term |= idr_long_term;
1511            pic.mmco_ops.extend(mmco_ops);
1512        }
1513
1514        let pic = self.cur.as_mut().expect("pending picture set above");
1515        // Row-interleave (mb16::row_hook) needs the CURRENT slice's deblock
1516        // parameters during decode; `abl_deblock` resolved here so mb16 stays
1517        // knob-agnostic.
1518        pic.fd.set_deblock_params(
1519            deblock && !abl_deblock(),
1520            filter_offset_a,
1521            filter_offset_b,
1522            deblock_idc2,
1523        );
1524        let first = first_mb_in_slice.min(pic.total_mb);
1525        pic.route_bits += r.data().len() as u64;
1526        let next = if cabac {
1527            // cabac_alignment_one_bit → the slice data is byte-aligned from here.
1528            r.align_to_byte().map_err(|_| DecodeError::Truncated)?;
1529            let (data, start) = (r.data(), r.bit_pos() / 8);
1530            pic.fd
1531                .decode_slice_data_cabac(data, start, slice_qp, cabac_init_idc, is_i, is_p, first)
1532        } else {
1533            pic.fd.decode_slice_data(&mut r, is_p, first)
1534        }
1535        .map_err(|e| match e {
1536            mb16::MbError::Truncated => DecodeError::Truncated,
1537            mb16::MbError::Unsupported(s) => DecodeError::Unsupported(s),
1538        })?;
1539        pic.next_mb = next;
1540        pic.slice_count += 1;
1541        if crate::mb16::dump_mb_on() {
1542            eprintln!(
1543                "  slice decoded {}/{} MBs{}",
1544                next,
1545                pic.total_mb,
1546                if next < pic.total_mb {
1547                    "   <-- INCOMPLETE"
1548                } else {
1549                    ""
1550                }
1551            );
1552        }
1553
1554        if pic.next_mb < pic.total_mb {
1555            return Ok(None); // picture not yet complete
1556        }
1557
1558        // --- finalize the completed picture: evaluate the GATE 1 route ---
1559        let pic = self.cur.take().expect("pending picture");
1560        {
1561            let (skips, coded) = pic.fd.route_counters();
1562            let mbs = pic.total_mb.max(1) as f64;
1563            let bits_per_mb = pic.route_bits as f64 * 8.0 / mbs;
1564            let (skip_frac, coded_frac) = (skips as f64 / mbs, coded as f64 / mbs);
1565            let (eb, es, ec) = match self.route_ema {
1566                None => (bits_per_mb, skip_frac, coded_frac),
1567                Some((pb, ps, pc)) => (
1568                    pb + (bits_per_mb - pb) / 8.0,
1569                    ps + (skip_frac - ps) / 8.0,
1570                    pc + (coded_frac - pc) / 8.0,
1571                ),
1572            };
1573            self.route_ema = Some((eb, es, ec));
1574            let route = route_for(pic.route_cabac, pic.route_t8x8, eb, es, ec);
1575            self.last_route = Some(route);
1576            // Same treatment as RH264_DUMP_MB: a bare knob() here is env::var plus a
1577            // String allocation on EVERY picture, and it kept the eprintln! formatting
1578            // machinery compiled in for a dump nobody enables.
1579            if crate::mb16::route_dump_on() {
1580                eprintln!(
1581                    "ROUTE cabac={} t8x8={} bits_per_mb={bits_per_mb:.2} skip_frac={skip_frac:.4} coded_frac={coded_frac:.4} ema=({eb:.2},{es:.4},{ec:.4}) -> {route:?}",
1582                    pic.route_cabac, pic.route_t8x8
1583                );
1584            }
1585        }
1586        let PendingPic {
1587            mut fd,
1588            frame_num,
1589            poc,
1590            deblock,
1591            filter_offset_a,
1592            filter_offset_b,
1593            crop_r,
1594            crop_b,
1595            max_refs,
1596            log2_max_frame_num,
1597            is_reference,
1598            idr_long_term,
1599            mut mmco_ops,
1600            ..
1601        } = pic;
1602        self.last_poc = poc;
1603        // MEASUREMENT KNOB (`RFF_ABL_DEBLOCK=1`): skip the loop filter to price it
1604        // with ZERO instrument tax. The scope-based profiler charges an rdtsc pair
1605        // per scope, and at ~20M per-MB scopes that tax reached 1.3-1.4x of the
1606        // whole decode -- so a per-MB stage's share cannot be read off it. Ablation
1607        // on the UNINSTRUMENTED binary is the honest price. Output is wrong while
1608        // set; decode WORK is unchanged (the filter reads and writes samples but
1609        // decides nothing), so the timing stays comparable.
1610        if deblock && !abl_deblock() {
1611            fd.deblock(filter_offset_a, filter_offset_b);
1612        }
1613        // The necessary DPB plane clone (rec_y/u/v → RefFrame) — measured as its own
1614        // stage, OUTSIDE the Finalize scope so the two don't double-count.
1615        let reference = if is_reference {
1616            let _dg = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::DpbClone);
1617            Some(fd.as_reference_pooled(&mut self.plane_pool))
1618        } else {
1619            None
1620        };
1621        let _fg = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Finalize);
1622        if let Some(mut reference) = reference {
1623            reference.frame_num = frame_num;
1624            reference.poc = poc;
1625            if crate::mb16::dump_mb_on() {
1626                eprintln!("DPB-ADD fn={frame_num} poc={poc}");
1627            }
1628            if idr_long_term {
1629                reference.long_term = true;
1630                reference.long_term_idx = 0;
1631            }
1632            let reference = reference; // RefFrame from as_reference_pooled
1633            if self.detach_dpb {
1634                self.detached_mmco = core::mem::take(&mut mmco_ops);
1635                self.detached_frame_num = frame_num;
1636                self.detached_log2_max_frame_num = log2_max_frame_num;
1637                self.detached_max_refs = max_refs;
1638                self.detached_idr_long_term = idr_long_term;
1639                if let Some(slot) = self.progress_slot.take() {
1640                    // Phase B: fold finished planes into the pre-shared Arc.
1641                    Self::fill_progress_slot(&slot, reference);
1642                    slot.mark_fully_ready();
1643                    self.detached_ref = Some(slot);
1644                } else {
1645                    let arc = crate::sync::Arc::new(reference);
1646                    arc.mark_fully_ready();
1647                    self.detached_ref = Some(arc);
1648                }
1649            } else {
1650                reference.mark_fully_ready();
1651                self.prev_ref_frame_num = self.apply_ref_marking(
1652                    reference,
1653                    &mmco_ops,
1654                    frame_num,
1655                    log2_max_frame_num,
1656                    max_refs,
1657                );
1658            }
1659        }
1660        self.sc_mmco = mmco_ops; // scratch back (capacity survives)
1661        let (frame, pool) = fd.into_frame_recycle(crop_r, crop_b);
1662        self.grid_pool = pool;
1663        self.reclaim_retired();
1664        Ok(Some(frame))
1665    }
1666
1667    /// Frame-MT: commit an already-shared reference Arc (Phase B progress slot).
1668    /// Applies the detached picture's reference + MMCO onto `self.refs` and
1669    /// returns the updated `prev_ref_frame_num`.
1670    pub(crate) fn commit_detached_ref_arc(&mut self, reference: Ref) -> Result<u32, DecodeError> {
1671        reference.mark_fully_ready();
1672        let mmco = core::mem::take(&mut self.detached_mmco);
1673        let frame_num = self.detached_frame_num;
1674        let log2 = self.detached_log2_max_frame_num;
1675        let max_refs = self.detached_max_refs;
1676        self.prev_ref_frame_num =
1677            self.apply_ref_marking_arc(reference, &mmco, frame_num, log2, max_refs);
1678        Ok(self.prev_ref_frame_num)
1679    }
1680
1681    /// Fold a finished `as_reference_pooled` snapshot into a Phase B progress Arc:
1682    /// lock-free frozen planes + coloc meta (no steady-state RwLock on DPB MC).
1683    fn fill_progress_slot(slot: &Ref, mut finished: crate::RefFrame) {
1684        let planes = FrozenPlanes {
1685            py: core::mem::take(&mut finished.py),
1686            pu: core::mem::take(&mut finished.pu),
1687            pv: core::mem::take(&mut finished.pv),
1688        };
1689        if let Some(live) = &slot.live {
1690            let _w = live.wait.lock().unwrap();
1691            {
1692                let mut m = live.meta.write().unwrap();
1693                m.frame_num = finished.frame_num;
1694                m.poc = finished.poc;
1695                m.long_term = finished.long_term;
1696                m.long_term_idx = finished.long_term_idx;
1697                m.mv = core::mem::take(&mut finished.mv);
1698                m.ref_idx = core::mem::take(&mut finished.ref_idx);
1699                m.mv1 = core::mem::take(&mut finished.mv1);
1700                m.ref_idx1 = core::mem::take(&mut finished.ref_idx1);
1701                m.ref_poc = core::mem::take(&mut finished.ref_poc);
1702                m.w4 = finished.w4;
1703                m.motion_ready = true;
1704            }
1705            let _ = slot.frozen.set(planes);
1706            slot.ready_rows
1707                .store(slot.ch, core::sync::atomic::Ordering::Release);
1708            live.cv.notify_all();
1709        } else {
1710            let _ = slot.frozen.set(planes);
1711            slot.mark_fully_ready();
1712        }
1713    }
1714
1715    /// Moves the padded planes of retired (DPB-evicted) reference frames into
1716    /// the recycle pool. Called after the current picture's `FrameDecoder` is
1717    /// consumed, at which point a retired frame's `Arc` is normally unique; a
1718    /// frame something still holds (it shouldn't) is simply dropped un-recycled.
1719    fn reclaim_retired(&mut self) {
1720        for arc in self.retired.drain(..) {
1721            if let Ok(mut rf) = crate::sync::Arc::try_unwrap(arc) {
1722                if let Some(f) = rf.frozen.take() {
1723                    if !f.py.is_empty() {
1724                        self.plane_pool.push(f.py);
1725                        self.plane_pool.push(f.pu);
1726                        self.plane_pool.push(f.pv);
1727                    }
1728                } else if !rf.py.is_empty() {
1729                    self.plane_pool.push(rf.py);
1730                    self.plane_pool.push(rf.pu);
1731                    self.plane_pool.push(rf.pv);
1732                }
1733            }
1734        }
1735        // Bound the pool: 6 pictures' worth of planes (3 each) covers any
1736        // realistic ref churn; beyond that we'd just be hoarding memory.
1737        self.plane_pool.truncate(18);
1738    }
1739
1740    /// Inserts "non-existing" short-term reference frames for each `frame_num`
1741    /// skipped since the previous reference picture (spec §8.2.5.2). Their samples
1742    /// are unspecified (a conformant stream never references them); we use mid-grey
1743    /// so any accidental reference is benign. They occupy DPB slots and advance the
1744    /// sliding window, keeping PicNum/ref-list derivation correct.
1745    fn insert_frame_num_gaps(
1746        &mut self,
1747        frame_num: u32,
1748        max_fn: u32,
1749        max_refs: usize,
1750        w: usize,
1751        h: usize,
1752    ) {
1753        if max_fn == 0 {
1754            return;
1755        }
1756        let start = (self.prev_ref_frame_num + 1) % max_fn;
1757        let gap = (frame_num + max_fn - start) % max_fn;
1758        if gap == 0 {
1759            return;
1760        }
1761        // Each placeholder is inserted at the front then the DPB is truncated to
1762        // `max_refs`, so for a gap larger than that only the most recent `max_refs`
1763        // placeholders can survive. Materialise just those — a malformed stream can
1764        // declare a gap of MaxFrameNum-1 (up to 65535), and allocating that many
1765        // full frames would be a CPU/memory DoS.
1766        let cap = max_refs.max(1);
1767        let n = (gap as usize).min(cap);
1768        let (cw, ch) = (w, h);
1769        let mut expected = (frame_num + max_fn - n as u32) % max_fn;
1770        for _ in 0..n {
1771            self.refs.insert(
1772                0,
1773                crate::sync::Arc::new(RefFrame {
1774                    // Uniform grey: the padded plane of a uniform plane is itself.
1775                    py: vec![128; (cw + 2 * LPAD) * (ch + 2 * LPAD)],
1776                    pu: vec![128; (cw / 2 + 2 * CPAD) * (ch / 2 + 2 * CPAD)],
1777                    pv: vec![128; (cw / 2 + 2 * CPAD) * (ch / 2 + 2 * CPAD)],
1778                    cw,
1779                    ch,
1780                    ready_rows: core::sync::atomic::AtomicUsize::new(ch),
1781                    live: None,
1782                    frozen: crate::sync::Once::new(),
1783                    frame_num: expected,
1784                    poc: 0,
1785                    mv: Vec::new(),
1786                    ref_idx: Vec::new(),
1787                    mv1: Vec::new(),
1788                    ref_idx1: Vec::new(),
1789                    ref_poc: Vec::new(),
1790                    w4: 0,
1791                    long_term: false,
1792                    long_term_idx: 0,
1793                }),
1794            );
1795            self.refs.truncate(cap);
1796            expected = (expected + 1) % max_fn;
1797        }
1798        self.prev_ref_frame_num = (frame_num + max_fn - 1) % max_fn;
1799    }
1800
1801    /// The `PicOrderCnt` of the most recently returned picture. Pictures are
1802    /// returned in decode order; sorting them by this value yields display order
1803    /// (the only difference is reordered B-pictures).
1804    /// GATE 1 content route of the last completed picture — the 4-way cost
1805    /// tier from big-oppy-decoder §2. Trailing signal: consumers apply it to
1806    /// the NEXT picture. `None` until the first picture completes.
1807    pub fn content_route(&self) -> Option<ContentRoute> {
1808        self.last_route
1809    }
1810
1811    pub fn last_poc(&self) -> i32 {
1812        self.last_poc
1813    }
1814
1815    fn compute_poc(
1816        &mut self,
1817        sps: &Sps,
1818        is_idr: bool,
1819        nal_ref_idc: u8,
1820        frame_num: u32,
1821        poc_lsb: u32,
1822        delta_bottom: i32,
1823    ) -> i32 {
1824        self.poc
1825            .compute_poc(sps, is_idr, nal_ref_idc, frame_num, poc_lsb, delta_bottom)
1826    }
1827
1828    /// Inserts the just-decoded picture into the DPB and marks references
1829    /// (spec §8.2.5). With no MMCO commands this is the sliding window (evict the
1830    /// oldest short-term reference past capacity); with MMCO it is adaptive
1831    /// marking, including long-term assignment.
1832    ///
1833    /// Takes `reference` BY VALUE and MOVES it into the DPB (the caller's local is
1834    /// dropped right after) — the old `&mut` + `insert(0, reference.clone())` cloned
1835    /// all three planes (~1.35 MB/frame) a second time, on top of `as_reference`'s
1836    /// necessary clone. Returns the picture's final `frame_num` (0 after MMCO 5) for
1837    /// the caller's gap-detection tracking, since `reference` is gone after the move.
1838    fn apply_ref_marking(
1839        &mut self,
1840        mut reference: RefFrame,
1841        ops: &[Mmco],
1842        frame_num: u32,
1843        log2_max_frame_num: u32,
1844        max_refs: usize,
1845    ) -> u32 {
1846        let max = 1i64 << log2_max_frame_num;
1847        let curr = frame_num as i64;
1848        let pic_num = |rf: &RefFrame| -> i64 {
1849            if (rf.frame_num as i64) > curr {
1850                rf.frame_num as i64 - max
1851            } else {
1852                rf.frame_num as i64
1853            }
1854        };
1855
1856        if ops.is_empty() {
1857            // Sliding window: insert the current (short-term) picture, then evict
1858            // the oldest short-term reference while over capacity (long-term refs
1859            // are retained).
1860            let out_fn = reference.frame_num;
1861            self.refs.insert(0, crate::sync::Arc::new(reference));
1862            while self.refs.len() > max_refs {
1863                match self.refs.iter().rposition(|r| !r.long_term) {
1864                    Some(pos) => {
1865                        // Park the evicted frame; its planes are reclaimed on the
1866                        // next picture boundary (see `reclaim_retired`).
1867                        let evicted = self.refs.remove(pos);
1868                        self.retired.push(evicted);
1869                    }
1870                    None => break,
1871                }
1872            }
1873            return out_fn;
1874        }
1875
1876        // Adaptive marking (MMCO), applied in order.
1877        for &op in ops {
1878            match op {
1879                Mmco::Unref(diff) => {
1880                    let target = curr - (diff as i64 + 1);
1881                    self.refs.retain(|r| r.long_term || pic_num(r) != target);
1882                }
1883                Mmco::UnrefLong(ltpn) => {
1884                    self.refs
1885                        .retain(|r| !(r.long_term && r.long_term_idx == ltpn));
1886                }
1887                Mmco::AssignLong(diff, idx) => {
1888                    let target = curr - (diff as i64 + 1);
1889                    self.refs
1890                        .retain(|r| !(r.long_term && r.long_term_idx == idx));
1891                    for r in self.refs.iter_mut() {
1892                        if !r.long_term && pic_num(r) == target {
1893                            // Rare op; make_mut only copies if a slice still holds it.
1894                            let r = crate::sync::Arc::make_mut(r);
1895                            r.long_term = true;
1896                            r.long_term_idx = idx;
1897                        }
1898                    }
1899                }
1900                Mmco::MaxLong(max_plus1) => {
1901                    self.refs
1902                        .retain(|r| !(r.long_term && r.long_term_idx + 1 > max_plus1));
1903                }
1904                Mmco::Reset => {
1905                    self.refs.clear();
1906                    reference.frame_num = 0;
1907                }
1908                Mmco::CurrentLong(idx) => {
1909                    self.refs
1910                        .retain(|r| !(r.long_term && r.long_term_idx == idx));
1911                    reference.long_term = true;
1912                    reference.long_term_idx = idx;
1913                }
1914            }
1915        }
1916        let out_fn = reference.frame_num;
1917        self.refs.insert(0, crate::sync::Arc::new(reference));
1918        // Safety net so a malformed marking stream can't grow the DPB unbounded.
1919        let cap = max_refs.max(16);
1920        if self.refs.len() > cap {
1921            self.refs.truncate(cap);
1922        }
1923        out_fn
1924    }
1925
1926    /// Like [`Self::apply_ref_marking`] but inserts an existing `Arc` (Phase B
1927    /// progress slot / detached worker output).
1928    fn apply_ref_marking_arc(
1929        &mut self,
1930        mut reference: Ref,
1931        ops: &[Mmco],
1932        frame_num: u32,
1933        log2_max_frame_num: u32,
1934        max_refs: usize,
1935    ) -> u32 {
1936        let max = 1i64 << log2_max_frame_num;
1937        let curr = frame_num as i64;
1938        let pic_num = |rf: &RefFrame| -> i64 {
1939            let f = rf.fn_num() as i64;
1940            if f > curr {
1941                f - max
1942            } else {
1943                f
1944            }
1945        };
1946
1947        if ops.is_empty() {
1948            let out_fn = reference.fn_num();
1949            self.refs.insert(0, reference);
1950            while self.refs.len() > max_refs {
1951                match self.refs.iter().rposition(|r| !r.is_long_term()) {
1952                    Some(pos) => {
1953                        let evicted = self.refs.remove(pos);
1954                        self.retired.push(evicted);
1955                    }
1956                    None => break,
1957                }
1958            }
1959            return out_fn;
1960        }
1961
1962        for &op in ops {
1963            match op {
1964                Mmco::Unref(diff) => {
1965                    let target = curr - (diff as i64 + 1);
1966                    self.refs
1967                        .retain(|r| r.is_long_term() || pic_num(r) != target);
1968                }
1969                Mmco::UnrefLong(ltpn) => {
1970                    self.refs
1971                        .retain(|r| !(r.is_long_term() && r.lt_idx() == ltpn));
1972                }
1973                Mmco::AssignLong(diff, idx) => {
1974                    let target = curr - (diff as i64 + 1);
1975                    self.refs
1976                        .retain(|r| !(r.is_long_term() && r.lt_idx() == idx));
1977                    for r in self.refs.iter_mut() {
1978                        if !r.is_long_term() && pic_num(r) == target {
1979                            if r.live.is_some() {
1980                                r.set_long_term_marks(true, idx);
1981                            } else {
1982                                let r = crate::sync::Arc::make_mut(r);
1983                                r.long_term = true;
1984                                r.long_term_idx = idx;
1985                            }
1986                        }
1987                    }
1988                }
1989                Mmco::MaxLong(max_plus1) => {
1990                    self.refs
1991                        .retain(|r| !(r.is_long_term() && r.lt_idx() + 1 > max_plus1));
1992                }
1993                Mmco::Reset => {
1994                    self.refs.clear();
1995                    reference.set_frame_num_live(0);
1996                    if let Some(r) = crate::sync::Arc::get_mut(&mut reference) {
1997                        r.frame_num = 0;
1998                    }
1999                }
2000                Mmco::CurrentLong(idx) => {
2001                    self.refs
2002                        .retain(|r| !(r.is_long_term() && r.lt_idx() == idx));
2003                    reference.set_long_term_marks(true, idx);
2004                    if let Some(r) = crate::sync::Arc::get_mut(&mut reference) {
2005                        r.long_term = true;
2006                        r.long_term_idx = idx;
2007                    }
2008                }
2009            }
2010        }
2011        let out_fn = reference.fn_num();
2012        self.refs.insert(0, reference);
2013        let cap = max_refs.max(16);
2014        if self.refs.len() > cap {
2015            self.refs.truncate(cap);
2016        }
2017        out_fn
2018    }
2019}
2020
2021/// Emits a GOP's buffered pictures in display order (sorted by `PicOrderCnt`).
2022pub(crate) fn flush_gop(gop: &mut Vec<(i32, YuvFrame)>, out: &mut Vec<YuvFrame>) {
2023    gop.sort_by_key(|(poc, _)| *poc);
2024    out.extend(gop.drain(..).map(|(_, f)| f));
2025}
2026
2027/// Whether an access unit contains an IDR coded-slice NAL.
2028///
2029/// Public for harnesses that reimplement `decode_stream`'s display-order emit
2030/// with an early stop (e.g. correctness probes that only need the first N pictures).
2031pub fn au_is_idr(au: &[u8]) -> bool {
2032    split_annex_b(au)
2033        .iter()
2034        .any(|n| !n.is_empty() && NalUnitType::from_id(n[0]) == NalUnitType::IdrSlice)
2035}
2036
2037/// Splits an Annex-B byte stream into access units, each ending after a VCL
2038/// (coded-slice) NAL with any preceding parameter-set/SEI NALs attached. Start
2039/// codes are preserved so each unit can be passed straight to [`Decoder::decode`].
2040///
2041/// Public because [`Decoder::decode`] takes ONE access unit: a caller that wants
2042/// decode-order pictures, or wants to drop each picture as it arrives instead of
2043/// accumulating the stream like [`Decoder::decode_stream`], needs this to feed it.
2044pub fn split_access_units(stream: &[u8]) -> Vec<&[u8]> {
2045    // (offset of the start code, whether the NAL it begins is a VCL slice).
2046    let mut codes: Vec<(usize, bool)> = Vec::new();
2047    let mut i = 0;
2048    // `get(i..i + 3)` rather than `i + 3 <= len` plus three whole-buffer
2049    // indexes: the loop condition bounds `i` but LLVM re-checks each of the
2050    // three reads anyway, and this runs once per BYTE of the bitstream.
2051    while let Some(w) = stream.get(i..i + 3) {
2052        if w[0] == 0 && w[1] == 0 && w[2] == 1 {
2053            let nal_type = NalUnitType::from_id(stream.get(i + 3).copied().unwrap_or(0));
2054            let is_vcl = matches!(nal_type, NalUnitType::IdrSlice | NalUnitType::NonIdrSlice);
2055            // Include a leading zero (4-byte start code) in the unit boundary.
2056            let sc = if i > 0 && stream.get(i - 1) == Some(&0) {
2057                i - 1
2058            } else {
2059                i
2060            };
2061            codes.push((sc, is_vcl));
2062            i += 3;
2063        } else {
2064            i += 1;
2065        }
2066    }
2067    if codes.is_empty() {
2068        return vec![stream];
2069    }
2070    let mut aus = Vec::new();
2071    let mut start = codes[0].0;
2072    for k in 0..codes.len() {
2073        if codes[k].1 {
2074            let end = codes.get(k + 1).map_or(stream.len(), |c| c.0);
2075            aus.push(&stream[start..end]);
2076            start = end;
2077        }
2078    }
2079    aus
2080}
2081
2082/// Parses a `pred_weight_table()` (spec §7.3.3.2) for the active reference lists
2083/// (4:2:0 → chroma weights always present). List 1 is parsed only for B slices.
2084fn parse_pred_weight_table(
2085    r: &mut BitReader,
2086    num_l0: usize,
2087    num_l1: usize,
2088    is_b: bool,
2089) -> Result<WeightTable, DecodeError> {
2090    let luma_log2_denom = r.read_ue()? as i32;
2091    let chroma_log2_denom = r.read_ue()? as i32;
2092    // Spec §7.4.3.2 constrains both weight denoms to [0, 7]; a malformed stream can
2093    // carry any ue(v). Reject before `1 << denom` (which overflows for denom ≥ 31)
2094    // so a corrupt bitstream is rejected gracefully, never panics.
2095    if !(0..=7).contains(&luma_log2_denom) || !(0..=7).contains(&chroma_log2_denom) {
2096        return Err(DecodeError::Unsupported("invalid weight denom"));
2097    }
2098    let mut wt = WeightTable {
2099        luma_log2_denom,
2100        chroma_log2_denom,
2101        ..Default::default()
2102    };
2103    let lists: &[(usize, usize)] = if is_b {
2104        &[(0, num_l0), (1, num_l1)]
2105    } else {
2106        &[(0, num_l0)]
2107    };
2108    for &(list, n) in lists {
2109        let mut luma = Vec::with_capacity(n);
2110        let mut chroma = Vec::with_capacity(n);
2111        for _ in 0..n {
2112            let (mut lw, mut lo) = (1 << luma_log2_denom, 0);
2113            if r.read_bit()? {
2114                lw = r.read_se()?;
2115                lo = r.read_se()?;
2116            }
2117            luma.push((lw, lo));
2118            let mut ch = [(1 << chroma_log2_denom, 0); 2];
2119            if r.read_bit()? {
2120                for slot in ch.iter_mut() {
2121                    *slot = (r.read_se()?, r.read_se()?);
2122                }
2123            }
2124            chroma.push(ch);
2125        }
2126        wt.luma[list & 1] = luma;
2127        wt.chroma[list & 1] = chroma;
2128    }
2129    Ok(wt)
2130}
2131
2132/// Resolves the effective scaling matrices for a slice from the SPS lists and
2133/// any PPS override (fall-back rule B), returning them un-zig-zagged to raster
2134/// order: six 4×4 lists [Y/Cb/Cr intra, Y/Cb/Cr inter] and two 8×8 luma lists
2135/// [Y-intra, Y-inter].
2136fn resolve_scaling(sps: &Sps, pps: &Pps) -> ([[i32; 16]; 6], [[i32; 64]; 2]) {
2137    use crate::params::{
2138        DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA,
2139    };
2140    const ZZ4: [usize; 16] = [0, 1, 4, 8, 5, 2, 3, 6, 9, 12, 13, 10, 7, 11, 14, 15];
2141    // 8×8 frame zig-zag scan → raster index (spec Table 8-12).
2142    const ZZ8: [usize; 64] = [
2143        0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5, 12, 19, 26, 33, 40, 48, 41, 34, 27,
2144        20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51,
2145        58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
2146    ];
2147    // Effective zig-zag lists: a PPS override (rule B) takes precedence; an absent
2148    // PPS list falls back to the SPS list (or the default / previous PPS list).
2149    let mut z4 = [[16u8; 16]; 6];
2150    for i in 0..6 {
2151        z4[i] = if pps.pic_scaling_matrix_present {
2152            if pps.scaling_present_4x4[i] {
2153                pps.scaling_4x4[i]
2154            } else {
2155                match i {
2156                    0 if sps.has_scaling => sps.scaling_4x4[0],
2157                    0 => DEFAULT_4X4_INTRA,
2158                    3 if sps.has_scaling => sps.scaling_4x4[3],
2159                    3 => DEFAULT_4X4_INTER,
2160                    _ => z4[i - 1],
2161                }
2162            }
2163        } else {
2164            sps.scaling_4x4[i]
2165        };
2166    }
2167    let mut z8 = [[16u8; 64]; 2];
2168    for (i, list) in z8.iter_mut().enumerate() {
2169        *list = if pps.pic_scaling_matrix_present {
2170            if pps.scaling_present_8x8[i] {
2171                pps.scaling_8x8[i]
2172            } else if sps.has_scaling {
2173                sps.scaling_8x8[i]
2174            } else if i == 0 {
2175                DEFAULT_8X8_INTRA
2176            } else {
2177                DEFAULT_8X8_INTER
2178            }
2179        } else {
2180            sps.scaling_8x8[i]
2181        };
2182    }
2183    let mut out4 = [[16i32; 16]; 6];
2184    for (li, list) in out4.iter_mut().enumerate() {
2185        for k in 0..16 {
2186            list[ZZ4[k]] = z4[li][k] as i32;
2187        }
2188    }
2189    let mut out8 = [[16i32; 64]; 2];
2190    for (li, list) in out8.iter_mut().enumerate() {
2191        for k in 0..64 {
2192            list[ZZ8[k]] = z8[li][k] as i32;
2193        }
2194    }
2195    (out4, out8)
2196}
2197
2198/// Parses a `ref_pic_list_modification` command list (spec §7.3.3.1) into
2199/// `(modification_of_pic_nums_idc, value)` pairs, stopping at idc 3.
2200fn parse_ref_pic_list_modification(
2201    r: &mut BitReader,
2202    out: &mut Vec<(u32, u32)>,
2203) -> Result<(), DecodeError> {
2204    loop {
2205        let idc = r.read_ue()?;
2206        if idc == 3 {
2207            break;
2208        }
2209        if idc > 3 {
2210            return Err(DecodeError::Unsupported(
2211                "invalid ref_pic_list_modification",
2212            ));
2213        }
2214        let val = r.read_ue()?; // abs_diff_pic_num_minus1 / long_term_pic_num
2215        out.push((idc, val));
2216        if out.len() > 64 {
2217            return Err(DecodeError::Truncated); // runaway / corrupt
2218        }
2219    }
2220    Ok(())
2221}
2222
2223/// Builds the P-slice `RefPicList0`: short-term references ordered by descending
2224/// `FrameNumWrap`, then long-term by ascending idx (spec §8.2.4.2.1), with any
2225/// `ref_pic_list_modification` applied.
2226fn build_ref_list_p(
2227    dpb: &[Ref],
2228    curr_frame_num: u32,
2229    max_frame_num: u32,
2230    num_active: usize,
2231    mods: &[(u32, u32)],
2232) -> Result<Vec<Ref>, DecodeError> {
2233    let curr = curr_frame_num as i64;
2234    let max = max_frame_num as i64;
2235    let pic_num = |fnum: u32| -> i64 {
2236        let f = fnum as i64;
2237        if f > curr {
2238            f - max
2239        } else {
2240            f
2241        }
2242    };
2243    let mut init: Vec<Ref> = dpb.iter().filter(|r| !r.is_long_term()).cloned().collect();
2244    init.sort_by_key(|rf| core::cmp::Reverse(pic_num(rf.fn_num())));
2245    let mut long: Vec<Ref> = dpb.iter().filter(|r| r.is_long_term()).cloned().collect();
2246    long.sort_by_key(|rf| rf.lt_idx());
2247    init.extend(long);
2248    apply_list_modification(init, curr_frame_num, max_frame_num, num_active, mods)
2249}
2250
2251/// Builds the B-slice `RefPicList0` and `RefPicList1` (spec §8.2.4.2.3), ordered
2252/// by `PicOrderCnt` relative to the current picture: List0 leads with nearer
2253/// past pictures, List1 with nearer future pictures. Long-term references follow.
2254/// Per-list `ref_pic_list_modification` is then applied.
2255#[allow(clippy::too_many_arguments)]
2256fn build_ref_list_b(
2257    dpb: &[Ref],
2258    curr_poc: i32,
2259    curr_frame_num: u32,
2260    max_frame_num: u32,
2261    num0: usize,
2262    num1: usize,
2263    mods0: &[(u32, u32)],
2264    mods1: &[(u32, u32)],
2265) -> Result<(Vec<Ref>, Vec<Ref>), DecodeError> {
2266    let mut less: Vec<Ref> = dpb
2267        .iter()
2268        .filter(|r| !r.is_long_term() && r.pic_poc() < curr_poc)
2269        .cloned()
2270        .collect();
2271    let mut greater: Vec<Ref> = dpb
2272        .iter()
2273        .filter(|r| !r.is_long_term() && r.pic_poc() > curr_poc)
2274        .cloned()
2275        .collect();
2276    let mut long: Vec<Ref> = dpb.iter().filter(|r| r.is_long_term()).cloned().collect();
2277    less.sort_by_key(|r| core::cmp::Reverse(r.pic_poc())); // nearest past first
2278    greater.sort_by_key(|r| r.pic_poc()); // nearest future first
2279    long.sort_by_key(|r| r.lt_idx());
2280
2281    let mut init0 = less.clone();
2282    init0.extend(greater.clone());
2283    init0.extend(long.clone());
2284    let mut init1 = greater;
2285    init1.extend(less);
2286    init1.extend(long);
2287
2288    // When List1 (truncated to its active length) equals List0 and has more than
2289    // one entry, swap its first two entries (spec §8.2.4.2.3).
2290    let eq_len = num0.min(num1).min(init0.len()).min(init1.len());
2291    if num1 > 1
2292        && init1.len() > 1
2293        && (0..eq_len).all(|i| same_picture(&init0[i], &init1[i]))
2294        && eq_len == num1.min(init1.len())
2295        && eq_len == num0.min(init0.len())
2296    {
2297        // Slice pattern: `swap(0, 1)` checks both indexes even under the
2298        // `len() > 1` guard above; destructuring proves them.
2299        if let [a, b, ..] = &mut init1[..] {
2300            core::mem::swap(a, b);
2301        }
2302    }
2303
2304    let list0 = apply_list_modification(init0, curr_frame_num, max_frame_num, num0, mods0)?;
2305    let list1 = apply_list_modification(init1, curr_frame_num, max_frame_num, num1, mods1)?;
2306    Ok((list0, list1))
2307}
2308
2309/// Two DPB entries refer to the same picture (used for the List1 swap rule).
2310fn same_picture(a: &RefFrame, b: &RefFrame) -> bool {
2311    a.is_long_term() == b.is_long_term()
2312        && if a.is_long_term() {
2313            a.lt_idx() == b.lt_idx()
2314        } else {
2315            a.pic_poc() == b.pic_poc()
2316        }
2317}
2318
2319/// Applies `ref_pic_list_modification` to an initialized reference list and
2320/// truncates it to `num_active` (spec §8.2.4.3). `init` is the full ordered list;
2321/// the result is `num_active` entries, possibly reordered. idc 0/1 reference
2322/// short-term pictures by PicNum, idc 2 long-term ones by LongTermFrameIdx.
2323fn apply_list_modification(
2324    init: Vec<Ref>,
2325    curr_frame_num: u32,
2326    max_frame_num: u32,
2327    num_active: usize,
2328    mods: &[(u32, u32)],
2329) -> Result<Vec<Ref>, DecodeError> {
2330    if mods.is_empty() {
2331        let mut init = init;
2332        init.truncate(num_active.max(1));
2333        return Ok(init);
2334    }
2335    let curr = curr_frame_num as i64;
2336    let max = max_frame_num as i64;
2337    let mut list = init.clone();
2338    let mut pic_num_pred = curr;
2339    let mut refidx = 0usize;
2340    for &(idc, val) in mods {
2341        let matches: Box<dyn Fn(&RefFrame) -> bool> = if idc == 2 {
2342            Box::new(move |r: &RefFrame| r.is_long_term() && r.lt_idx() == val)
2343        } else {
2344            let abs_diff = (val as i64) + 1;
2345            let no_wrap = if idc == 0 {
2346                let x = pic_num_pred - abs_diff;
2347                if x < 0 {
2348                    x + max
2349                } else {
2350                    x
2351                }
2352            } else {
2353                let x = pic_num_pred + abs_diff;
2354                if x >= max {
2355                    x - max
2356                } else {
2357                    x
2358                }
2359            };
2360            pic_num_pred = no_wrap;
2361            let target = if no_wrap > curr {
2362                no_wrap - max
2363            } else {
2364                no_wrap
2365            };
2366            Box::new(move |r: &RefFrame| {
2367                let f = r.fn_num() as i64;
2368                let pn = if f > curr { f - max } else { f };
2369                !r.is_long_term() && pn == target
2370            })
2371        };
2372        let found = init.iter().find(|r| matches(r)).cloned();
2373        let Some(found) = found else {
2374            if crate::mb16::dump_mb_on() {
2375                let cand: Vec<String> = init
2376                    .iter()
2377                    .map(|r| {
2378                        let f = r.fn_num() as i64;
2379                        let pn = if f > curr { f - max } else { f };
2380                        format!(
2381                            "(fn={} poc={} lt={} picnum={})",
2382                            r.fn_num(),
2383                            r.pic_poc(),
2384                            r.is_long_term(),
2385                            pn
2386                        )
2387                    })
2388                    .collect();
2389                eprintln!(
2390                    "MODFAIL idc={idc} val={val}  curr_frame_num={curr} max={max}  init={}",
2391                    cand.join(" ")
2392                );
2393            }
2394            return Err(DecodeError::Truncated); // references a picture not in the DPB
2395        };
2396        if refidx > list.len() {
2397            break;
2398        }
2399        list.insert(refidx, found);
2400        if let Some(dup) = list
2401            .iter()
2402            .enumerate()
2403            .skip(refidx + 1)
2404            .find(|(_, r)| matches(r))
2405            .map(|(i, _)| i)
2406        {
2407            list.remove(dup);
2408        }
2409        refidx += 1;
2410        if refidx >= num_active {
2411            break;
2412        }
2413    }
2414    list.truncate(num_active.max(1));
2415    Ok(list)
2416}
2417
2418#[cfg(test)]
2419mod tests {
2420    use super::*;
2421
2422    fn ref_at(poc: i32, fnum: u32) -> Ref {
2423        crate::sync::Arc::new(RefFrame {
2424            py: vec![],
2425            pu: vec![],
2426            pv: vec![],
2427            cw: 0,
2428            ch: 0,
2429            ready_rows: core::sync::atomic::AtomicUsize::new(0),
2430            live: None,
2431            frozen: crate::sync::Once::new(),
2432            frame_num: fnum,
2433            poc,
2434            mv: Vec::new(),
2435            ref_idx: Vec::new(),
2436            mv1: Vec::new(),
2437            ref_idx1: Vec::new(),
2438            ref_poc: Vec::new(),
2439            w4: 0,
2440            long_term: false,
2441            long_term_idx: 0,
2442        })
2443    }
2444
2445    #[test]
2446    fn b_ref_lists_ordered_by_poc() {
2447        // Current POC 4; DPB has past (0,2) and future (6,8) references.
2448        let dpb = vec![ref_at(8, 4), ref_at(6, 3), ref_at(2, 1), ref_at(0, 0)];
2449        let (l0, l1) = build_ref_list_b(&dpb, 4, 5, 16, 4, 4, &[], &[]).unwrap();
2450        // List0: nearer past first (desc), then nearer future (asc).
2451        assert_eq!(
2452            l0.iter().map(|r| r.poc).collect::<Vec<_>>(),
2453            vec![2, 0, 6, 8]
2454        );
2455        // List1: nearer future first (asc), then nearer past (desc).
2456        assert_eq!(
2457            l1.iter().map(|r| r.poc).collect::<Vec<_>>(),
2458            vec![6, 8, 2, 0]
2459        );
2460    }
2461
2462    #[test]
2463    fn b_ref_list1_swap_when_equal() {
2464        // Only past references -> List0 and List1 initialize identically, so
2465        // List1's first two entries are swapped (spec §8.2.4.2.3).
2466        let dpb = vec![ref_at(4, 2), ref_at(2, 1), ref_at(0, 0)];
2467        let (l0, l1) = build_ref_list_b(&dpb, 6, 3, 16, 3, 3, &[], &[]).unwrap();
2468        assert_eq!(l0.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![4, 2, 0]);
2469        assert_eq!(l1.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![2, 4, 0]);
2470    }
2471
2472    #[test]
2473    fn frame_num_gaps_insert_placeholders() {
2474        let mut d = Decoder::new();
2475        d.prev_ref_frame_num = 2;
2476        // frame_num jumps 2 -> 5: placeholders for the skipped 3 and 4.
2477        d.insert_frame_num_gaps(5, 16, 8, 16, 16);
2478        let fns: Vec<u32> = d.refs.iter().map(|r| r.frame_num).collect();
2479        assert_eq!(fns, vec![4, 3], "most-recent placeholder at the front");
2480        assert_eq!(d.prev_ref_frame_num, 4);
2481        assert!(
2482            d.refs.iter().all(|r| r.py.iter().all(|&p| p == 128)),
2483            "grey fill"
2484        );
2485    }
2486
2487    #[test]
2488    fn frame_num_gaps_wrap_and_noop() {
2489        // Wrap across MaxFrameNum: prev 14, frame_num 1 (max 16) -> fill 15, 0.
2490        let mut d = Decoder::new();
2491        d.prev_ref_frame_num = 14;
2492        d.insert_frame_num_gaps(1, 16, 8, 16, 16);
2493        assert_eq!(
2494            d.refs.iter().map(|r| r.frame_num).collect::<Vec<_>>(),
2495            vec![0, 15]
2496        );
2497        // No gap (consecutive) inserts nothing.
2498        let mut d = Decoder::new();
2499        d.prev_ref_frame_num = 3;
2500        d.insert_frame_num_gaps(4, 16, 8, 16, 16);
2501        assert!(d.refs.is_empty());
2502    }
2503
2504    #[test]
2505    fn missing_param_sets_errors() {
2506        let mut d = Decoder::new();
2507        // A lone (fake) IDR slice header: first_mb_in_slice=0, slice_type=7 (I),
2508        // pic_parameter_set_id=0 — then the PPS lookup fails (none stored).
2509        let nal = rusty_h264_common::NalUnit::new(3, NalUnitType::IdrSlice, vec![0x88, 0x80]);
2510        let err = d.decode(&nal.to_annex_b()).unwrap_err();
2511        assert_eq!(err, DecodeError::MissingParameterSet);
2512    }
2513}