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