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