Skip to main content

rusty_h264_decoder/
lib.rs

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