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 (not yet: `I_PCM`, High-profile 8×8 residual).
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
25mod cabac;
26mod mb16;
27mod params;
28
29pub use params::{Pps, Sps};
30pub use mb16::{MvField, MV_DUMP};
31
32/// Test-only re-export of the CABAC arithmetic *decoder* so the encoder crate can
33/// round-trip-validate its CABAC *encoder* against the exact reference engine.
34#[doc(hidden)]
35pub mod cabac_test {
36    pub use crate::cabac::Cabac;
37}
38
39use mb16::{FrameDecoder, WeightTable};
40use rusty_h264_common::bit_reader::OutOfData;
41use rusty_h264_common::nal::{emulation_unprevent, split_annex_b};
42use rusty_h264_common::{BitReader, NalUnitType, YuvFrame};
43
44/// Decode errors.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum DecodeError {
47    /// Bitstream ended unexpectedly.
48    Truncated,
49    /// A required parameter set was missing before a slice.
50    MissingParameterSet,
51    /// A coding tool outside the implemented subset appeared in the stream.
52    Unsupported(&'static str),
53}
54
55impl From<OutOfData> for DecodeError {
56    fn from(_: OutOfData) -> Self {
57        DecodeError::Truncated
58    }
59}
60
61impl core::fmt::Display for DecodeError {
62    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
63        match self {
64            DecodeError::Truncated => f.write_str("bitstream truncated"),
65            DecodeError::MissingParameterSet => f.write_str("slice before SPS/PPS"),
66            DecodeError::Unsupported(s) => write!(f, "unsupported coding tool: {s}"),
67        }
68    }
69}
70
71impl std::error::Error for DecodeError {}
72
73/// A reference picture: deblocked reconstruction at coded resolution.
74/// Stored now (4a); read by motion compensation in 4b.
75/// Shared handle to a reference picture. The DPB and every per-slice reference
76/// list hold `Arc`s: list construction used to DEEP-CLONE each entry's planes +
77/// motion grids per slice (H-32 found ~600 KB+/slice of pure memcpy on B
78/// streams); an `Arc` clone is a refcount bump reading the same bytes, so the
79/// change is byte-identical by construction. `Arc::make_mut` covers the one
80/// mutation site (MMCO long-term marking).
81pub(crate) type Ref = std::sync::Arc<RefFrame>;
82
83#[derive(Debug, Clone, Default)]
84#[allow(dead_code)]
85pub(crate) struct RefFrame {
86    /// EDGE-PADDED planes (openh264 `ExpandPicture`): built ONCE per reference
87    /// frame so motion compensation reads them in place — the per-MC-call
88    /// clamped-tile extraction (~400 B copied per call, ~100 MB/clip on real
89    /// streams) dies with this. Luma pad [`LPAD`], chroma pad [`CPAD`]; strides
90    /// via [`RefFrame::lstride`]/[`RefFrame::cstride`].
91    pub py: Vec<u8>,
92    pub pu: Vec<u8>,
93    pub pv: Vec<u8>,
94    pub cw: usize,
95    pub ch: usize,
96    /// `frame_num` of the picture, for PicNum-based reference-list reordering.
97    pub frame_num: u32,
98    /// `PicOrderCnt` of the picture, for B-slice reference-list ordering.
99    pub poc: i32,
100    /// Per-4×4-block List-0 motion field (motion vector + reference index, `-1`
101    /// for intra), and the block-grid width. Read as the *co-located* picture's
102    /// motion for B-slice direct prediction (`colZeroFlag`, temporal direct).
103    pub mv: Vec<(i32, i32)>,
104    pub ref_idx: Vec<i32>,
105    /// Per-4×4-block POC of the List-0 picture each block referenced (`i32::MIN`
106    /// for intra). Used by temporal direct's `MapColToList0` (the co-located
107    /// reference index alone is meaningless in the current list).
108    pub ref_poc: Vec<i32>,
109    pub w4: usize,
110    /// Long-term reference state. Long-term refs sit after short-term ones in
111    /// `RefPicList0` (ordered by `long_term_idx` ascending) and survive the
112    /// sliding window until explicitly unmarked (spec §8.2.4).
113    pub long_term: bool,
114    pub long_term_idx: u32,
115}
116
117/// Luma / chroma pad of every [`RefFrame`] plane. Luma 16 serves MVs overshooting
118/// the picture by up to ~14 px in place (chroma: half that, matching); wilder MVs
119/// take `mc_*_padded`'s clamped-halo fallback — correct, just slower.
120pub(crate) const LPAD: usize = 16;
121pub(crate) const CPAD: usize = 8;
122
123impl RefFrame {
124    #[inline]
125    pub fn lstride(&self) -> usize {
126        self.cw + 2 * LPAD
127    }
128    #[inline]
129    pub fn cstride(&self) -> usize {
130        self.cw / 2 + 2 * CPAD
131    }
132}
133
134/// A memory-management control operation (`dec_ref_pic_marking`, spec §7.4.3.3).
135#[derive(Clone, Copy)]
136enum Mmco {
137    /// 1: mark a short-term reference (by PicNum) as unused.
138    Unref(u32),
139    /// 2: mark a long-term reference (by LongTermPicNum) as unused.
140    UnrefLong(u32),
141    /// 3: assign a short-term reference (by PicNum) a LongTermFrameIdx.
142    AssignLong(u32, u32),
143    /// 4: drop long-term references with idx ≥ max_long_term_frame_idx_plus1.
144    MaxLong(u32),
145    /// 5: empty the DPB (and reset the current picture's frame_num to 0).
146    Reset,
147    /// 6: mark the current picture long-term with this LongTermFrameIdx.
148    CurrentLong(u32),
149}
150
151/// A picture being assembled from one or more slices (spec allows a picture to
152/// be split into multiple slices). Finalized — deblocked, output, and entered
153/// into the DPB — once all its macroblocks are decoded.
154struct PendingPic {
155    fd: mb16::FrameDecoder,
156    frame_num: u32,
157    poc: i32,
158    next_mb: usize,
159    total_mb: usize,
160    slice_count: u16,
161    deblock: bool,
162    filter_offset_a: i32,
163    filter_offset_b: i32,
164    crop_r: usize,
165    crop_b: usize,
166    max_refs: usize,
167    log2_max_frame_num: u32,
168    /// `false` for a non-reference picture (nal_ref_idc == 0): output it but do
169    /// not enter it into the DPB.
170    is_reference: bool,
171    idr_long_term: bool,
172    mmco_ops: Vec<Mmco>,
173}
174
175/// A Constrained Baseline H.264 decoder. Holds the most recent parameter sets
176/// and the previous decoded picture (the inter reference) across calls.
177#[derive(Default)]
178pub struct Decoder {
179    /// Active parameter sets, keyed by id — a stream may carry several and switch
180    /// between them per slice (spec §7.3.2.1/.2).
181    sps: std::collections::HashMap<u32, Sps>,
182    pps: std::collections::HashMap<u32, Pps>,
183    /// Decoded-picture buffer (most-recent first); `ref_idx` indexes into this.
184    refs: Vec<Ref>,
185    /// The picture currently being assembled from its slices, if any.
186    cur: Option<PendingPic>,
187    /// Picture-order-count state (spec §8.2.1). Tracks the previous reference
188    /// picture's MSB/LSB (type 0) and frame-num offset (types 1/2) so display
189    /// order can be recovered — needed once B-pictures (out-of-order) land.
190    poc: PocState,
191    /// `PicOrderCnt` of the most recently returned picture (display-order key).
192    last_poc: i32,
193    /// `frame_num` of the previous short-term reference picture, for detecting
194    /// gaps in `frame_num` (spec §8.2.5.2).
195    prev_ref_frame_num: u32,
196}
197
198/// Running picture-order-count derivation state.
199#[derive(Default)]
200struct PocState {
201    prev_msb: i32,
202    prev_lsb: i32,
203    prev_frame_num: u32,
204    prev_frame_num_offset: i64,
205}
206
207impl Decoder {
208    /// Creates a decoder with no parameter sets yet.
209    pub fn new() -> Self {
210        Self::default()
211    }
212
213    /// Decodes a complete Annex-B access unit, returning the reconstructed,
214    /// cropped frame if the access unit contained a coded picture.
215    pub fn decode(&mut self, annex_b: &[u8]) -> Result<Option<YuvFrame>, DecodeError> {
216        let _g = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Total);
217        let mut frame = None;
218        for nal in split_annex_b(annex_b) {
219            if nal.is_empty() {
220                continue;
221            }
222            let nal_type = NalUnitType::from_id(nal[0]);
223            let rbsp = emulation_unprevent(&nal[1..]);
224            match nal_type {
225                NalUnitType::Sps => {
226                    let s = Sps::parse(&rbsp)?;
227                    self.sps.insert(s.seq_parameter_set_id, s);
228                }
229                NalUnitType::Pps => {
230                    let p = Pps::parse(&rbsp)?;
231                    self.pps.insert(p.pic_parameter_set_id, p);
232                }
233                NalUnitType::IdrSlice | NalUnitType::NonIdrSlice => {
234                    let nal_ref_idc = (nal[0] >> 5) & 3;
235                    let is_idr = nal_type == NalUnitType::IdrSlice;
236                    if let Some(f) = self.decode_slice(&rbsp, is_idr, nal_ref_idc)? {
237                        frame = Some(f);
238                    }
239                }
240                _ => {} // SEI, AUD, etc. ignored
241            }
242        }
243        Ok(frame)
244    }
245
246    /// Decodes a complete Annex-B byte stream and returns every picture in
247    /// **display order** (`PicOrderCnt` within each GOP; an IDR ends a GOP).
248    ///
249    /// This is the convenient whole-stream entry point — it handles access-unit
250    /// splitting, multi-slice picture assembly, and B-picture reordering — versus
251    /// the lower-level per-access-unit [`Decoder::decode`], which returns pictures
252    /// in decode order.
253    pub fn decode_stream(&mut self, annex_b: &[u8]) -> Result<Vec<YuvFrame>, DecodeError> {
254        let mut out = Vec::new();
255        let mut gop: Vec<(i32, YuvFrame)> = Vec::new();
256        for au in split_access_units(annex_b) {
257            if au_is_idr(au) {
258                flush_gop(&mut gop, &mut out); // emit the prior GOP before the IDR
259            }
260            if let Some(frame) = self.decode(au)? {
261                gop.push((self.last_poc, frame));
262            }
263        }
264        flush_gop(&mut gop, &mut out);
265        Ok(out)
266    }
267
268    fn decode_slice(
269        &mut self,
270        rbsp: &[u8],
271        is_idr: bool,
272        nal_ref_idc: u8,
273    ) -> Result<Option<YuvFrame>, DecodeError> {
274        let mut r = BitReader::new(rbsp);
275        // --- slice_header ---
276        let first_mb_in_slice = r.read_ue()? as usize;
277        let slice_type = r.read_ue()?;
278        let is_p = matches!(slice_type, 0 | 5);
279        let is_b = matches!(slice_type, 1 | 6);
280        let is_i = matches!(slice_type, 2 | 7);
281        if !is_p && !is_b && !is_i {
282            return Err(DecodeError::Unsupported("SP/SI slices"));
283        }
284        // Resolve the parameter sets this slice references (by id).
285        let pic_parameter_set_id = r.read_ue()?;
286        let pps = self.pps.get(&pic_parameter_set_id).cloned().ok_or(DecodeError::MissingParameterSet)?;
287        let sps = self.sps.get(&pps.seq_parameter_set_id).cloned().ok_or(DecodeError::MissingParameterSet)?;
288        let sps = &sps;
289        let pps = &pps;
290        // CABAC (entropy_coding_mode_flag=1) has an entirely different slice-data parse
291        // (docs/cabac-decode-plan.md). I-slice CABAC is being brought up; the CABAC MB
292        // loop gates P/B until Phase 3. `cabac_init_idc` (P/B only) is read below.
293        let cabac = pps.entropy_coding_mode_flag;
294        let frame_num = r.read_bits(sps.log2_max_frame_num)?;
295        if is_idr {
296            let _idr_pic_id = r.read_ue()?;
297        }
298        // pic_order_cnt fields (spec §7.3.3). `field_pic_flag` is always 0
299        // (frame_mbs_only). Captured to derive PicOrderCnt for display ordering.
300        let mut poc_lsb = 0u32;
301        let mut delta_poc_bottom = 0i32;
302        if sps.pic_order_cnt_type == 0 {
303            poc_lsb = r.read_bits(sps.log2_max_pic_order_cnt_lsb)?;
304            if pps.bottom_field_pic_order_present {
305                delta_poc_bottom = r.read_se()?;
306            }
307        } else if sps.pic_order_cnt_type == 1 && !sps.delta_pic_order_always_zero {
308            let _delta_pic_order_cnt_0 = r.read_se()?;
309            if pps.bottom_field_pic_order_present {
310                let _delta_pic_order_cnt_1 = r.read_se()?;
311            }
312        }
313        // PicOrderCnt is determined by the first slice of the picture; later
314        // slices share it (and must not re-advance the POC state).
315        let pic_poc = if first_mb_in_slice == 0 {
316            self.compute_poc(sps, is_idr, nal_ref_idc, frame_num, poc_lsb, delta_poc_bottom)
317        } else {
318            self.cur.as_ref().map_or(0, |p| p.poc)
319        };
320        // redundant_pic_cnt: a non-zero value marks a *redundant* coded picture
321        // (an alternative representation of the primary picture). A primary
322        // decoder discards it (spec §7.4.3, §8.2.5 note). Must be read here or the
323        // rest of the slice header desyncs.
324        if pps.redundant_pic_cnt_present_flag {
325            let redundant_pic_cnt = r.read_ue()?;
326            if redundant_pic_cnt != 0 {
327                return Ok(None);
328            }
329        }
330        // B slices choose direct-mode derivation here (spec §7.3.3).
331        let direct_spatial = if is_b { r.read_bit()? } else { true };
332        let mut num_ref_idx_l0 = pps.num_ref_idx_l0_default as usize;
333        let mut num_ref_idx_l1 = pps.num_ref_idx_l1_default as usize;
334        let mut reorder_l0: Vec<(u32, u32)> = Vec::new();
335        let mut reorder_l1: Vec<(u32, u32)> = Vec::new();
336        if is_p || is_b {
337            // num_ref_idx_active_override_flag
338            if r.read_bit()? {
339                num_ref_idx_l0 = (r.read_ue()? + 1) as usize;
340                if is_b {
341                    num_ref_idx_l1 = (r.read_ue()? + 1) as usize;
342                }
343            }
344            // ref_pic_list_modification_flag_l0
345            if r.read_bit()? {
346                parse_ref_pic_list_modification(&mut r, &mut reorder_l0)?;
347            }
348            if is_b && r.read_bit()? {
349                // ref_pic_list_modification_flag_l1
350                parse_ref_pic_list_modification(&mut r, &mut reorder_l1)?;
351            }
352        }
353        // Explicit weighted prediction carries a pred_weight_table() here. P
354        // (weighted_pred) uses single-list weights; B explicit bipred (idc 1) is
355        // not yet wired into the bi-pred averaging, so refuse that. Implicit
356        // bipred (idc 2) carries no table.
357        let weights = if is_p && pps.weighted_pred {
358            Some(parse_pred_weight_table(&mut r, num_ref_idx_l0, 0, false)?)
359        } else if is_b && pps.weighted_bipred_idc == 1 {
360            return Err(DecodeError::Unsupported("explicit B weighted prediction"));
361        } else {
362            None
363        };
364        // dec_ref_pic_marking (spec §7.3.3.3) — present only for reference
365        // pictures (nal_ref_idc != 0). Reading it for a non-reference slice would
366        // desync the rest of the header.
367        let mut idr_long_term = false;
368        let mut mmco_ops: Vec<Mmco> = Vec::new();
369        if nal_ref_idc == 0 {
370            // non-reference picture: no marking syntax
371        } else if is_idr {
372            let _no_output_of_prior_pics = r.read_bit()?;
373            idr_long_term = r.read_bit()?; // long_term_reference_flag
374        } else if r.read_bit()? {
375            // adaptive_ref_pic_marking_mode_flag
376            loop {
377                let op = r.read_ue()?;
378                match op {
379                    0 => break,
380                    1 => mmco_ops.push(Mmco::Unref(r.read_ue()?)),
381                    2 => mmco_ops.push(Mmco::UnrefLong(r.read_ue()?)),
382                    3 => {
383                        let diff = r.read_ue()?;
384                        let idx = r.read_ue()?;
385                        mmco_ops.push(Mmco::AssignLong(diff, idx));
386                    }
387                    4 => mmco_ops.push(Mmco::MaxLong(r.read_ue()?)),
388                    5 => mmco_ops.push(Mmco::Reset),
389                    6 => mmco_ops.push(Mmco::CurrentLong(r.read_ue()?)),
390                    _ => return Err(DecodeError::Unsupported("invalid MMCO")),
391                }
392                if mmco_ops.len() > 128 {
393                    return Err(DecodeError::Truncated);
394                }
395            }
396        }
397        // cabac_init_idc (spec §7.3.3) — CABAC context-model preset, P/B slices only.
398        // Spec range [0,2]; a larger (corrupt) value would index the 4-model context-init
399        // table out of bounds, so reject it here.
400        let cabac_init_idc = if cabac && !is_i {
401            let v = r.read_ue()?;
402            if v > 2 {
403                return Err(DecodeError::Unsupported("invalid cabac_init_idc"));
404            }
405            v
406        } else {
407            0
408        };
409        let slice_qp_delta = r.read_se()?;
410        // When deblocking_filter_control_present_flag is 0 the slice carries no
411        // disable_deblocking_filter_idc and it is inferred 0 — i.e. the in-loop
412        // filter is ON by default (spec §7.4.3). (Our own encoder always signals
413        // the control explicitly, so this default was previously untested.)
414        let mut deblock = true;
415        let (mut filter_offset_a, mut filter_offset_b) = (0i32, 0i32);
416        if pps.deblocking_filter_control_present_flag {
417            let disable_deblocking_filter_idc = r.read_ue()?;
418            // idc 1 = filter off; idc 0 = on; idc 2 = on but not across slice
419            // boundaries (equivalent to on for single-slice pictures).
420            deblock = disable_deblocking_filter_idc != 1;
421            if disable_deblocking_filter_idc != 1 {
422                // FilterOffset = slice_*_offset_div2 × 2 (spec §7.4.3).
423                filter_offset_a = r.read_se()? * 2;
424                filter_offset_b = r.read_se()? * 2;
425            }
426        }
427        let slice_qp = (pps.pic_init_qp + slice_qp_delta).clamp(0, 51) as u8;
428
429        // Synthesize placeholder short-term references for any gap in frame_num
430        // (spec §8.2.5.2) so the DPB / PicNum mapping stays correct.
431        if first_mb_in_slice == 0 && !is_idr && sps.gaps_in_frame_num_allowed {
432            self.insert_frame_num_gaps(
433                frame_num,
434                1u32 << sps.log2_max_frame_num,
435                sps.max_num_ref_frames.max(1) as usize,
436                sps.pic_width_in_mbs * 16,
437                sps.pic_height_in_mbs * 16,
438            );
439        }
440
441        // Build the reference list(s) for this slice. P uses RefPicList0 only;
442        // B uses RefPicList0 and RefPicList1 (POC-ordered).
443        let max_fn = 1u32 << sps.log2_max_frame_num;
444        let (ref_list0, ref_list1) = if is_b {
445            build_ref_list_b(
446                &self.refs, pic_poc, frame_num, max_fn,
447                num_ref_idx_l0, num_ref_idx_l1, &reorder_l0, &reorder_l1,
448            )?
449        } else if is_p {
450            (build_ref_list_p(&self.refs, frame_num, max_fn, num_ref_idx_l0, &reorder_l0)?, Vec::new())
451        } else {
452            (Vec::new(), Vec::new())
453        };
454        // --- picture assembly ---
455        // first_mb_in_slice == 0 starts a new picture; otherwise this slice
456        // continues the one in flight. An IDR clears the DPB at its first slice.
457        if first_mb_in_slice == 0 {
458            if is_idr {
459                self.refs.clear();
460            }
461            let mut fd = FrameDecoder::new(
462                sps.pic_width_in_mbs,
463                sps.pic_height_in_mbs,
464                slice_qp,
465                pps.chroma_qp_index_offset,
466                ref_list0,
467                num_ref_idx_l0,
468                pps.constrained_intra_pred_flag,
469                pps.transform_8x8_mode_flag,
470                sps.profile_idc != 66, // b_possible: Baseline/Constrained Baseline (66) forbid B
471            );
472            if is_b {
473                fd.set_b_context(
474                    ref_list1,
475                    num_ref_idx_l1,
476                    direct_spatial,
477                    pic_poc,
478                    pps.weighted_bipred_idc,
479                    sps.direct_8x8_inference,
480                );
481            }
482            if sps.has_scaling || pps.pic_scaling_matrix_present {
483                let (s4, s8) = resolve_scaling(sps, pps);
484                fd.set_scaling(s4, s8);
485            }
486            if let Some(w) = weights {
487                fd.set_weights(w);
488            }
489            self.cur = Some(PendingPic {
490                fd,
491                frame_num,
492                poc: pic_poc,
493                next_mb: 0,
494                total_mb: sps.pic_width_in_mbs * sps.pic_height_in_mbs,
495                slice_count: 0,
496                deblock,
497                filter_offset_a,
498                filter_offset_b,
499                crop_r: sps.frame_crop_right as usize,
500                crop_b: sps.frame_crop_bottom as usize,
501                max_refs: sps.max_num_ref_frames.max(1) as usize,
502                log2_max_frame_num: sps.log2_max_frame_num,
503                is_reference: nal_ref_idc != 0,
504                idr_long_term,
505                mmco_ops,
506            });
507        } else {
508            // Continuation slice: reset the per-slice QP + reference list.
509            let Some(pic) = self.cur.as_mut() else {
510                return Err(DecodeError::Unsupported("slice continues a missing picture"));
511            };
512            pic.fd.begin_slice(slice_qp, ref_list0, num_ref_idx_l0);
513            if is_b {
514                pic.fd.set_b_context(
515                    ref_list1,
516                    num_ref_idx_l1,
517                    direct_spatial,
518                    pic.poc,
519                    pps.weighted_bipred_idc,
520                    sps.direct_8x8_inference,
521                );
522            }
523            if sps.has_scaling || pps.pic_scaling_matrix_present {
524                let (s4, s8) = resolve_scaling(sps, pps);
525                pic.fd.set_scaling(s4, s8);
526            }
527            if let Some(w) = weights {
528                pic.fd.set_weights(w);
529            }
530            // Latest slice's marking/deblock parameters win at finalization.
531            pic.deblock = deblock;
532            pic.filter_offset_a = filter_offset_a;
533            pic.filter_offset_b = filter_offset_b;
534            pic.idr_long_term |= idr_long_term;
535            pic.mmco_ops.extend(mmco_ops);
536        }
537
538        let pic = self.cur.as_mut().expect("pending picture set above");
539        let first = first_mb_in_slice.min(pic.total_mb);
540        let next = if cabac {
541            // cabac_alignment_one_bit → the slice data is byte-aligned from here.
542            r.align_to_byte().map_err(|_| DecodeError::Truncated)?;
543            let (data, start) = (r.data(), r.bit_pos() / 8);
544            pic.fd
545                .decode_slice_data_cabac(data, start, slice_qp, cabac_init_idc, is_i, is_p, first)
546        } else {
547            pic.fd.decode_slice_data(&mut r, is_p, first)
548        }
549        .map_err(|e| match e {
550            mb16::MbError::Truncated => DecodeError::Truncated,
551            mb16::MbError::Unsupported(s) => DecodeError::Unsupported(s),
552        })?;
553        pic.next_mb = next;
554        pic.slice_count += 1;
555
556        if pic.next_mb < pic.total_mb {
557            return Ok(None); // picture not yet complete
558        }
559
560        // --- finalize the completed picture ---
561        let pic = self.cur.take().expect("pending picture");
562        let PendingPic {
563            mut fd,
564            frame_num,
565            poc,
566            deblock,
567            filter_offset_a,
568            filter_offset_b,
569            crop_r,
570            crop_b,
571            max_refs,
572            log2_max_frame_num,
573            is_reference,
574            idr_long_term,
575            mmco_ops,
576            ..
577        } = pic;
578        self.last_poc = poc;
579        if deblock {
580            fd.deblock(filter_offset_a, filter_offset_b);
581        }
582        // The necessary DPB plane clone (rec_y/u/v → RefFrame) — measured as its own
583        // stage, OUTSIDE the Finalize scope so the two don't double-count.
584        let reference = if is_reference {
585            let _dg = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::DpbClone);
586            Some(fd.as_reference())
587        } else {
588            // A non-reference picture is output but never enters the DPB.
589            None
590        };
591        let _fg = rusty_h264_common::prof::scope(rusty_h264_common::prof::Stage::Finalize);
592        if let Some(mut reference) = reference {
593            reference.frame_num = frame_num;
594            reference.poc = poc;
595            if idr_long_term {
596                reference.long_term = true;
597                reference.long_term_idx = 0;
598            }
599            // Track the reference frame_num for gap detection (0 after MMCO 5).
600            self.prev_ref_frame_num =
601                self.apply_ref_marking(reference, &mmco_ops, frame_num, log2_max_frame_num, max_refs);
602        }
603        Ok(Some(fd.into_frame(crop_r, crop_b)))
604    }
605
606    /// Inserts "non-existing" short-term reference frames for each `frame_num`
607    /// skipped since the previous reference picture (spec §8.2.5.2). Their samples
608    /// are unspecified (a conformant stream never references them); we use mid-grey
609    /// so any accidental reference is benign. They occupy DPB slots and advance the
610    /// sliding window, keeping PicNum/ref-list derivation correct.
611    fn insert_frame_num_gaps(&mut self, frame_num: u32, max_fn: u32, max_refs: usize, w: usize, h: usize) {
612        if max_fn == 0 {
613            return;
614        }
615        let start = (self.prev_ref_frame_num + 1) % max_fn;
616        let gap = (frame_num + max_fn - start) % max_fn;
617        if gap == 0 {
618            return;
619        }
620        // Each placeholder is inserted at the front then the DPB is truncated to
621        // `max_refs`, so for a gap larger than that only the most recent `max_refs`
622        // placeholders can survive. Materialise just those — a malformed stream can
623        // declare a gap of MaxFrameNum-1 (up to 65535), and allocating that many
624        // full frames would be a CPU/memory DoS.
625        let cap = max_refs.max(1);
626        let n = (gap as usize).min(cap);
627        let (cw, ch) = (w, h);
628        let mut expected = (frame_num + max_fn - n as u32) % max_fn;
629        for _ in 0..n {
630            self.refs.insert(
631                0,
632                std::sync::Arc::new(RefFrame {
633                    // Uniform grey: the padded plane of a uniform plane is itself.
634                    py: vec![128; (cw + 2 * LPAD) * (ch + 2 * LPAD)],
635                    pu: vec![128; (cw / 2 + 2 * CPAD) * (ch / 2 + 2 * CPAD)],
636                    pv: vec![128; (cw / 2 + 2 * CPAD) * (ch / 2 + 2 * CPAD)],
637                    cw,
638                    ch,
639                    frame_num: expected,
640                    poc: 0,
641                    mv: Vec::new(),
642                    ref_idx: Vec::new(),
643                    ref_poc: Vec::new(),
644                    w4: 0,
645                    long_term: false,
646                    long_term_idx: 0,
647                }),
648            );
649            self.refs.truncate(cap);
650            expected = (expected + 1) % max_fn;
651        }
652        self.prev_ref_frame_num = (frame_num + max_fn - 1) % max_fn;
653    }
654
655    /// The `PicOrderCnt` of the most recently returned picture. Pictures are
656    /// returned in decode order; sorting them by this value yields display order
657    /// (the only difference is reordered B-pictures).
658    pub fn last_poc(&self) -> i32 {
659        self.last_poc
660    }
661
662    /// Derives `PicOrderCnt` for the current picture (spec §8.2.1) and advances
663    /// the POC state. Types 0 and 2 are exact; type 1 is approximated by
664    /// frame-num order (no B-stream in scope uses it).
665    fn compute_poc(
666        &mut self,
667        sps: &Sps,
668        is_idr: bool,
669        nal_ref_idc: u8,
670        frame_num: u32,
671        poc_lsb: u32,
672        delta_bottom: i32,
673    ) -> i32 {
674        match sps.pic_order_cnt_type {
675            0 => {
676                let max_lsb = 1i32 << sps.log2_max_pic_order_cnt_lsb;
677                let (prev_msb, prev_lsb) =
678                    if is_idr { (0, 0) } else { (self.poc.prev_msb, self.poc.prev_lsb) };
679                let lsb = poc_lsb as i32;
680                let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 {
681                    prev_msb + max_lsb
682                } else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 {
683                    prev_msb - max_lsb
684                } else {
685                    prev_msb
686                };
687                let top = msb + lsb;
688                let poc = top.min(top + delta_bottom);
689                if nal_ref_idc != 0 {
690                    self.poc.prev_msb = msb;
691                    self.poc.prev_lsb = lsb;
692                }
693                poc
694            }
695            2 => {
696                let max_fn = 1i64 << sps.log2_max_frame_num;
697                let offset = if is_idr {
698                    0
699                } else if self.poc.prev_frame_num > frame_num {
700                    self.poc.prev_frame_num_offset + max_fn
701                } else {
702                    self.poc.prev_frame_num_offset
703                };
704                let poc = if is_idr {
705                    0
706                } else {
707                    2 * (offset + frame_num as i64) - i64::from(nal_ref_idc == 0)
708                };
709                self.poc.prev_frame_num_offset = offset;
710                self.poc.prev_frame_num = frame_num;
711                poc as i32
712            }
713            _ => {
714                self.poc.prev_frame_num = frame_num;
715                frame_num as i32 * 2
716            }
717        }
718    }
719
720    /// Inserts the just-decoded picture into the DPB and marks references
721    /// (spec §8.2.5). With no MMCO commands this is the sliding window (evict the
722    /// oldest short-term reference past capacity); with MMCO it is adaptive
723    /// marking, including long-term assignment.
724    ///
725    /// Takes `reference` BY VALUE and MOVES it into the DPB (the caller's local is
726    /// dropped right after) — the old `&mut` + `insert(0, reference.clone())` cloned
727    /// all three planes (~1.35 MB/frame) a second time, on top of `as_reference`'s
728    /// necessary clone. Returns the picture's final `frame_num` (0 after MMCO 5) for
729    /// the caller's gap-detection tracking, since `reference` is gone after the move.
730    fn apply_ref_marking(
731        &mut self,
732        mut reference: RefFrame,
733        ops: &[Mmco],
734        frame_num: u32,
735        log2_max_frame_num: u32,
736        max_refs: usize,
737    ) -> u32 {
738        let max = 1i64 << log2_max_frame_num;
739        let curr = frame_num as i64;
740        let pic_num = |rf: &RefFrame| -> i64 {
741            if (rf.frame_num as i64) > curr {
742                rf.frame_num as i64 - max
743            } else {
744                rf.frame_num as i64
745            }
746        };
747
748        if ops.is_empty() {
749            // Sliding window: insert the current (short-term) picture, then evict
750            // the oldest short-term reference while over capacity (long-term refs
751            // are retained).
752            let out_fn = reference.frame_num;
753            self.refs.insert(0, std::sync::Arc::new(reference));
754            while self.refs.len() > max_refs {
755                match self.refs.iter().rposition(|r| !r.long_term) {
756                    Some(pos) => {
757                        self.refs.remove(pos);
758                    }
759                    None => break,
760                }
761            }
762            return out_fn;
763        }
764
765        // Adaptive marking (MMCO), applied in order.
766        for &op in ops {
767            match op {
768                Mmco::Unref(diff) => {
769                    let target = curr - (diff as i64 + 1);
770                    self.refs.retain(|r| r.long_term || pic_num(r) != target);
771                }
772                Mmco::UnrefLong(ltpn) => {
773                    self.refs.retain(|r| !(r.long_term && r.long_term_idx == ltpn));
774                }
775                Mmco::AssignLong(diff, idx) => {
776                    let target = curr - (diff as i64 + 1);
777                    self.refs.retain(|r| !(r.long_term && r.long_term_idx == idx));
778                    for r in self.refs.iter_mut() {
779                        if !r.long_term && pic_num(r) == target {
780                            // Rare op; make_mut only copies if a slice still holds it.
781                            let r = std::sync::Arc::make_mut(r);
782                            r.long_term = true;
783                            r.long_term_idx = idx;
784                        }
785                    }
786                }
787                Mmco::MaxLong(max_plus1) => {
788                    self.refs.retain(|r| !(r.long_term && r.long_term_idx + 1 > max_plus1));
789                }
790                Mmco::Reset => {
791                    self.refs.clear();
792                    reference.frame_num = 0;
793                }
794                Mmco::CurrentLong(idx) => {
795                    self.refs.retain(|r| !(r.long_term && r.long_term_idx == idx));
796                    reference.long_term = true;
797                    reference.long_term_idx = idx;
798                }
799            }
800        }
801        let out_fn = reference.frame_num;
802        self.refs.insert(0, std::sync::Arc::new(reference));
803        // Safety net so a malformed marking stream can't grow the DPB unbounded.
804        let cap = max_refs.max(16);
805        if self.refs.len() > cap {
806            self.refs.truncate(cap);
807        }
808        out_fn
809    }
810}
811
812/// Emits a GOP's buffered pictures in display order (sorted by `PicOrderCnt`).
813fn flush_gop(gop: &mut Vec<(i32, YuvFrame)>, out: &mut Vec<YuvFrame>) {
814    gop.sort_by_key(|(poc, _)| *poc);
815    out.extend(gop.drain(..).map(|(_, f)| f));
816}
817
818/// Whether an access unit contains an IDR coded-slice NAL.
819fn au_is_idr(au: &[u8]) -> bool {
820    split_annex_b(au)
821        .iter()
822        .any(|n| !n.is_empty() && NalUnitType::from_id(n[0]) == NalUnitType::IdrSlice)
823}
824
825/// Splits an Annex-B byte stream into access units, each ending after a VCL
826/// (coded-slice) NAL with any preceding parameter-set/SEI NALs attached. Start
827/// codes are preserved so each unit can be passed straight to [`Decoder::decode`].
828fn split_access_units(stream: &[u8]) -> Vec<&[u8]> {
829    // (offset of the start code, whether the NAL it begins is a VCL slice).
830    let mut codes: Vec<(usize, bool)> = Vec::new();
831    let mut i = 0;
832    while i + 3 <= stream.len() {
833        if stream[i] == 0 && stream[i + 1] == 0 && stream[i + 2] == 1 {
834            let nal_type = NalUnitType::from_id(stream.get(i + 3).copied().unwrap_or(0));
835            let is_vcl = matches!(nal_type, NalUnitType::IdrSlice | NalUnitType::NonIdrSlice);
836            // Include a leading zero (4-byte start code) in the unit boundary.
837            let sc = if i > 0 && stream[i - 1] == 0 { i - 1 } else { i };
838            codes.push((sc, is_vcl));
839            i += 3;
840        } else {
841            i += 1;
842        }
843    }
844    if codes.is_empty() {
845        return vec![stream];
846    }
847    let mut aus = Vec::new();
848    let mut start = codes[0].0;
849    for k in 0..codes.len() {
850        if codes[k].1 {
851            let end = codes.get(k + 1).map_or(stream.len(), |c| c.0);
852            aus.push(&stream[start..end]);
853            start = end;
854        }
855    }
856    aus
857}
858
859/// Parses a `pred_weight_table()` (spec §7.3.3.2) for the active reference lists
860/// (4:2:0 → chroma weights always present). List 1 is parsed only for B slices.
861fn parse_pred_weight_table(
862    r: &mut BitReader,
863    num_l0: usize,
864    num_l1: usize,
865    is_b: bool,
866) -> Result<WeightTable, DecodeError> {
867    let luma_log2_denom = r.read_ue()? as i32;
868    let chroma_log2_denom = r.read_ue()? as i32;
869    // Spec §7.4.3.2 constrains both weight denoms to [0, 7]; a malformed stream can
870    // carry any ue(v). Reject before `1 << denom` (which overflows for denom ≥ 31)
871    // so a corrupt bitstream is rejected gracefully, never panics.
872    if !(0..=7).contains(&luma_log2_denom) || !(0..=7).contains(&chroma_log2_denom) {
873        return Err(DecodeError::Unsupported("invalid weight denom"));
874    }
875    let mut wt = WeightTable {
876        luma_log2_denom,
877        chroma_log2_denom,
878        ..Default::default()
879    };
880    let lists: &[(usize, usize)] = if is_b {
881        &[(0, num_l0), (1, num_l1)]
882    } else {
883        &[(0, num_l0)]
884    };
885    for &(list, n) in lists {
886        let mut luma = Vec::with_capacity(n);
887        let mut chroma = Vec::with_capacity(n);
888        for _ in 0..n {
889            let (mut lw, mut lo) = (1 << luma_log2_denom, 0);
890            if r.read_bit()? {
891                lw = r.read_se()?;
892                lo = r.read_se()?;
893            }
894            luma.push((lw, lo));
895            let mut ch = [(1 << chroma_log2_denom, 0); 2];
896            if r.read_bit()? {
897                for slot in ch.iter_mut() {
898                    *slot = (r.read_se()?, r.read_se()?);
899                }
900            }
901            chroma.push(ch);
902        }
903        wt.luma[list] = luma;
904        wt.chroma[list] = chroma;
905    }
906    Ok(wt)
907}
908
909/// Resolves the effective scaling matrices for a slice from the SPS lists and
910/// any PPS override (fall-back rule B), returning them un-zig-zagged to raster
911/// order: six 4×4 lists [Y/Cb/Cr intra, Y/Cb/Cr inter] and two 8×8 luma lists
912/// [Y-intra, Y-inter].
913fn resolve_scaling(sps: &Sps, pps: &Pps) -> ([[i32; 16]; 6], [[i32; 64]; 2]) {
914    use crate::params::{
915        DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA,
916    };
917    const ZZ4: [usize; 16] = [0, 1, 4, 8, 5, 2, 3, 6, 9, 12, 13, 10, 7, 11, 14, 15];
918    // 8×8 frame zig-zag scan → raster index (spec Table 8-12).
919    const ZZ8: [usize; 64] = [
920        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,
921        20, 13, 6, 7, 14, 21, 28, 35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51,
922        58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63,
923    ];
924    // Effective zig-zag lists: a PPS override (rule B) takes precedence; an absent
925    // PPS list falls back to the SPS list (or the default / previous PPS list).
926    let mut z4 = [[16u8; 16]; 6];
927    for i in 0..6 {
928        z4[i] = if pps.pic_scaling_matrix_present {
929            if pps.scaling_present_4x4[i] {
930                pps.scaling_4x4[i]
931            } else {
932                match i {
933                    0 if sps.has_scaling => sps.scaling_4x4[0],
934                    0 => DEFAULT_4X4_INTRA,
935                    3 if sps.has_scaling => sps.scaling_4x4[3],
936                    3 => DEFAULT_4X4_INTER,
937                    _ => z4[i - 1],
938                }
939            }
940        } else {
941            sps.scaling_4x4[i]
942        };
943    }
944    let mut z8 = [[16u8; 64]; 2];
945    for (i, list) in z8.iter_mut().enumerate() {
946        *list = if pps.pic_scaling_matrix_present {
947            if pps.scaling_present_8x8[i] {
948                pps.scaling_8x8[i]
949            } else if sps.has_scaling {
950                sps.scaling_8x8[i]
951            } else if i == 0 {
952                DEFAULT_8X8_INTRA
953            } else {
954                DEFAULT_8X8_INTER
955            }
956        } else {
957            sps.scaling_8x8[i]
958        };
959    }
960    let mut out4 = [[16i32; 16]; 6];
961    for (li, list) in out4.iter_mut().enumerate() {
962        for k in 0..16 {
963            list[ZZ4[k]] = z4[li][k] as i32;
964        }
965    }
966    let mut out8 = [[16i32; 64]; 2];
967    for (li, list) in out8.iter_mut().enumerate() {
968        for k in 0..64 {
969            list[ZZ8[k]] = z8[li][k] as i32;
970        }
971    }
972    (out4, out8)
973}
974
975/// Parses a `ref_pic_list_modification` command list (spec §7.3.3.1) into
976/// `(modification_of_pic_nums_idc, value)` pairs, stopping at idc 3.
977fn parse_ref_pic_list_modification(
978    r: &mut BitReader,
979    out: &mut Vec<(u32, u32)>,
980) -> Result<(), DecodeError> {
981    loop {
982        let idc = r.read_ue()?;
983        if idc == 3 {
984            break;
985        }
986        if idc > 3 {
987            return Err(DecodeError::Unsupported("invalid ref_pic_list_modification"));
988        }
989        let val = r.read_ue()?; // abs_diff_pic_num_minus1 / long_term_pic_num
990        out.push((idc, val));
991        if out.len() > 64 {
992            return Err(DecodeError::Truncated); // runaway / corrupt
993        }
994    }
995    Ok(())
996}
997
998/// Builds the P-slice `RefPicList0`: short-term references ordered by descending
999/// `FrameNumWrap`, then long-term by ascending idx (spec §8.2.4.2.1), with any
1000/// `ref_pic_list_modification` applied.
1001fn build_ref_list_p(
1002    dpb: &[Ref],
1003    curr_frame_num: u32,
1004    max_frame_num: u32,
1005    num_active: usize,
1006    mods: &[(u32, u32)],
1007) -> Result<Vec<Ref>, DecodeError> {
1008    let curr = curr_frame_num as i64;
1009    let max = max_frame_num as i64;
1010    let pic_num = |fnum: u32| -> i64 {
1011        let f = fnum as i64;
1012        if f > curr { f - max } else { f }
1013    };
1014    let mut init: Vec<Ref> = dpb.iter().filter(|r| !r.long_term).cloned().collect();
1015    init.sort_by_key(|rf| core::cmp::Reverse(pic_num(rf.frame_num)));
1016    let mut long: Vec<Ref> = dpb.iter().filter(|r| r.long_term).cloned().collect();
1017    long.sort_by_key(|rf| rf.long_term_idx);
1018    init.extend(long);
1019    apply_list_modification(init, curr_frame_num, max_frame_num, num_active, mods)
1020}
1021
1022/// Builds the B-slice `RefPicList0` and `RefPicList1` (spec §8.2.4.2.3), ordered
1023/// by `PicOrderCnt` relative to the current picture: List0 leads with nearer
1024/// past pictures, List1 with nearer future pictures. Long-term references follow.
1025/// Per-list `ref_pic_list_modification` is then applied.
1026#[allow(clippy::too_many_arguments)]
1027fn build_ref_list_b(
1028    dpb: &[Ref],
1029    curr_poc: i32,
1030    curr_frame_num: u32,
1031    max_frame_num: u32,
1032    num0: usize,
1033    num1: usize,
1034    mods0: &[(u32, u32)],
1035    mods1: &[(u32, u32)],
1036) -> Result<(Vec<Ref>, Vec<Ref>), DecodeError> {
1037    let mut less: Vec<Ref> =
1038        dpb.iter().filter(|r| !r.long_term && r.poc < curr_poc).cloned().collect();
1039    let mut greater: Vec<Ref> =
1040        dpb.iter().filter(|r| !r.long_term && r.poc > curr_poc).cloned().collect();
1041    let mut long: Vec<Ref> = dpb.iter().filter(|r| r.long_term).cloned().collect();
1042    less.sort_by_key(|r| core::cmp::Reverse(r.poc)); // nearest past first
1043    greater.sort_by_key(|r| r.poc); // nearest future first
1044    long.sort_by_key(|r| r.long_term_idx);
1045
1046    let mut init0 = less.clone();
1047    init0.extend(greater.clone());
1048    init0.extend(long.clone());
1049    let mut init1 = greater;
1050    init1.extend(less);
1051    init1.extend(long);
1052
1053    // When List1 (truncated to its active length) equals List0 and has more than
1054    // one entry, swap its first two entries (spec §8.2.4.2.3).
1055    let eq_len = num0.min(num1).min(init0.len()).min(init1.len());
1056    if num1 > 1
1057        && init1.len() > 1
1058        && (0..eq_len).all(|i| same_picture(&init0[i], &init1[i]))
1059        && eq_len == num1.min(init1.len())
1060        && eq_len == num0.min(init0.len())
1061    {
1062        init1.swap(0, 1);
1063    }
1064
1065    let list0 = apply_list_modification(init0, curr_frame_num, max_frame_num, num0, mods0)?;
1066    let list1 = apply_list_modification(init1, curr_frame_num, max_frame_num, num1, mods1)?;
1067    Ok((list0, list1))
1068}
1069
1070/// Two DPB entries refer to the same picture (used for the List1 swap rule).
1071fn same_picture(a: &RefFrame, b: &RefFrame) -> bool {
1072    a.long_term == b.long_term
1073        && if a.long_term { a.long_term_idx == b.long_term_idx } else { a.poc == b.poc }
1074}
1075
1076/// Applies `ref_pic_list_modification` to an initialized reference list and
1077/// truncates it to `num_active` (spec §8.2.4.3). `init` is the full ordered list;
1078/// the result is `num_active` entries, possibly reordered. idc 0/1 reference
1079/// short-term pictures by PicNum, idc 2 long-term ones by LongTermFrameIdx.
1080fn apply_list_modification(
1081    init: Vec<Ref>,
1082    curr_frame_num: u32,
1083    max_frame_num: u32,
1084    num_active: usize,
1085    mods: &[(u32, u32)],
1086) -> Result<Vec<Ref>, DecodeError> {
1087    if mods.is_empty() {
1088        let mut init = init;
1089        init.truncate(num_active.max(1));
1090        return Ok(init);
1091    }
1092    let curr = curr_frame_num as i64;
1093    let max = max_frame_num as i64;
1094    let mut list = init.clone();
1095    let mut pic_num_pred = curr;
1096    let mut refidx = 0usize;
1097    for &(idc, val) in mods {
1098        let matches: Box<dyn Fn(&RefFrame) -> bool> = if idc == 2 {
1099            Box::new(move |r: &RefFrame| r.long_term && r.long_term_idx == val)
1100        } else {
1101            let abs_diff = (val as i64) + 1;
1102            let no_wrap = if idc == 0 {
1103                let x = pic_num_pred - abs_diff;
1104                if x < 0 { x + max } else { x }
1105            } else {
1106                let x = pic_num_pred + abs_diff;
1107                if x >= max { x - max } else { x }
1108            };
1109            pic_num_pred = no_wrap;
1110            let target = if no_wrap > curr { no_wrap - max } else { no_wrap };
1111            Box::new(move |r: &RefFrame| {
1112                let pn = if r.frame_num as i64 > curr {
1113                    r.frame_num as i64 - max
1114                } else {
1115                    r.frame_num as i64
1116                };
1117                !r.long_term && pn == target
1118            })
1119        };
1120        let found = init.iter().find(|r| matches(r)).cloned();
1121        let Some(found) = found else {
1122            return Err(DecodeError::Truncated); // references a picture not in the DPB
1123        };
1124        if refidx > list.len() {
1125            break;
1126        }
1127        list.insert(refidx, found);
1128        if let Some(dup) = list.iter().enumerate().skip(refidx + 1).find(|(_, r)| matches(r)).map(|(i, _)| i) {
1129            list.remove(dup);
1130        }
1131        refidx += 1;
1132        if refidx >= num_active {
1133            break;
1134        }
1135    }
1136    list.truncate(num_active.max(1));
1137    Ok(list)
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142    use super::*;
1143
1144    fn ref_at(poc: i32, fnum: u32) -> Ref {
1145        std::sync::Arc::new(RefFrame {
1146            py: vec![],
1147            pu: vec![],
1148            pv: vec![],
1149            cw: 0,
1150            ch: 0,
1151            frame_num: fnum,
1152            poc,
1153            mv: Vec::new(),
1154            ref_idx: Vec::new(),
1155            ref_poc: Vec::new(),
1156            w4: 0,
1157            long_term: false,
1158            long_term_idx: 0,
1159        })
1160    }
1161
1162    #[test]
1163    fn b_ref_lists_ordered_by_poc() {
1164        // Current POC 4; DPB has past (0,2) and future (6,8) references.
1165        let dpb = vec![ref_at(8, 4), ref_at(6, 3), ref_at(2, 1), ref_at(0, 0)];
1166        let (l0, l1) = build_ref_list_b(&dpb, 4, 5, 16, 4, 4, &[], &[]).unwrap();
1167        // List0: nearer past first (desc), then nearer future (asc).
1168        assert_eq!(l0.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![2, 0, 6, 8]);
1169        // List1: nearer future first (asc), then nearer past (desc).
1170        assert_eq!(l1.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![6, 8, 2, 0]);
1171    }
1172
1173    #[test]
1174    fn b_ref_list1_swap_when_equal() {
1175        // Only past references -> List0 and List1 initialize identically, so
1176        // List1's first two entries are swapped (spec §8.2.4.2.3).
1177        let dpb = vec![ref_at(4, 2), ref_at(2, 1), ref_at(0, 0)];
1178        let (l0, l1) = build_ref_list_b(&dpb, 6, 3, 16, 3, 3, &[], &[]).unwrap();
1179        assert_eq!(l0.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![4, 2, 0]);
1180        assert_eq!(l1.iter().map(|r| r.poc).collect::<Vec<_>>(), vec![2, 4, 0]);
1181    }
1182
1183    #[test]
1184    fn frame_num_gaps_insert_placeholders() {
1185        let mut d = Decoder::new();
1186        d.prev_ref_frame_num = 2;
1187        // frame_num jumps 2 -> 5: placeholders for the skipped 3 and 4.
1188        d.insert_frame_num_gaps(5, 16, 8, 16, 16);
1189        let fns: Vec<u32> = d.refs.iter().map(|r| r.frame_num).collect();
1190        assert_eq!(fns, vec![4, 3], "most-recent placeholder at the front");
1191        assert_eq!(d.prev_ref_frame_num, 4);
1192        assert!(d.refs.iter().all(|r| r.py.iter().all(|&p| p == 128)), "grey fill");
1193    }
1194
1195    #[test]
1196    fn frame_num_gaps_wrap_and_noop() {
1197        // Wrap across MaxFrameNum: prev 14, frame_num 1 (max 16) -> fill 15, 0.
1198        let mut d = Decoder::new();
1199        d.prev_ref_frame_num = 14;
1200        d.insert_frame_num_gaps(1, 16, 8, 16, 16);
1201        assert_eq!(d.refs.iter().map(|r| r.frame_num).collect::<Vec<_>>(), vec![0, 15]);
1202        // No gap (consecutive) inserts nothing.
1203        let mut d = Decoder::new();
1204        d.prev_ref_frame_num = 3;
1205        d.insert_frame_num_gaps(4, 16, 8, 16, 16);
1206        assert!(d.refs.is_empty());
1207    }
1208
1209    #[test]
1210    fn missing_param_sets_errors() {
1211        let mut d = Decoder::new();
1212        // A lone (fake) IDR slice header: first_mb_in_slice=0, slice_type=7 (I),
1213        // pic_parameter_set_id=0 — then the PPS lookup fails (none stored).
1214        let nal = rusty_h264_common::NalUnit::new(3, NalUnitType::IdrSlice, vec![0x88, 0x80]);
1215        let err = d.decode(&nal.to_annex_b()).unwrap_err();
1216        assert_eq!(err, DecodeError::MissingParameterSet);
1217    }
1218}