From a34f4051fc9a093926bb4c5184e2d77630602b6c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 00:41:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(pf-vkdecode):=20the=20CPU=20half=20of=20HE?= =?UTF-8?q?VC=20decode=20=E2=80=94=20StdVideo=20H265=20conversion=20+=20sl?= =?UTF-8?q?ot=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3 WP-2, first half. params_h265.rs: VPS/SPS/PPS -> StdVideoH265* with owned pointer-backing (the params.rs contract), scaling lists incl. the 32x32 two-matrix quirk and +8 DC convention, short-term RPS re-encoded from the parser's RESOLVED DeltaPoc arrays back into delta_poc_sX_minus1 syntax under monotonicity checks, fallback_vps_from_sps for streams whose VPS NALU was lost. pic_h265.rs: plan_to_vk_h265 — h265 AuPlan -> StdVideoDecodeH265PictureInfo + per-reference infos; the binding set is the union of the three current RPS sets with the Std index arrays indexing into refs (0xFF unused; the GPU half must lay pReferenceSlots out in refs order); NumDeltaPocsOfRefRpsIdx from the predicted-from candidate; transactional SlotMap lifecycle identical to pic.rs. SlotMap reused unmodified — HEVC's ceiling equals H.264's 16+1. Envelope fails closed: Main/Main10/MainStill/RExt only, 4:2:0-8/10 + 4:4:4 only (separate_colour_plane_flag rejected — ChromaArrayType 0 in disguise), SCC palette predictors out, >64 ST RPS sets / >16 per side / >32 LT SPS candidates out, checked narrowing on every narrower Std field. No panics on untrusted input. Review round 9 (adversarial): RPS re-encode math, Std field-by-field conformance, transactionality and slot ceiling verified clean; 6 findings fixed pre-commit. Headline (BLOCKING): long_term_ref_pics_ present_flag=1 with num=0 left pLongTermRefPicsSps NULL — the header demands a valid pointer whenever the flag is set, and flag=1/num=0 is exactly the punktfunk LTR/RFI recovery stream shape; the all-zero backing now rides whenever the flag is set. Also: the slice_offsets doc in BOTH pic modules claimed submit-as-planned while decoder.rs packs slices-only and rebases (non-VCL NALUs in the decode range hang VCN firmware) — reworded so the HEVC GPU half cannot implement the hang; a concealment-produced ST/LT duplicate now ORs the long-term flag across occurrences; NumDeltaPocs clamps became a typed error; dead UnmappableLevelIdc variant dropped. Deferred to the GPU half: HEVC caps/profile chain, session parameters (VPS leg in the ledger), P010/4:4:4 pool selection, recording, and the pReferenceSlots-in-refs-order contract consumption. Gates: fmt clean; mac pf-vkdecode 80 + pf-bitstream 69 green, clippy clean; container clippy -D warnings zero (pf-client-core, pf-presenter, pf-vkdecode) + tests green (69/121/80). --- crates/pf-vkdecode/src/lib.rs | 27 + crates/pf-vkdecode/src/params_h265.rs | 1813 +++++++++++++++++++++++++ crates/pf-vkdecode/src/pic.rs | 10 +- crates/pf-vkdecode/src/pic_h265.rs | 1116 +++++++++++++++ 4 files changed, 2963 insertions(+), 3 deletions(-) create mode 100644 crates/pf-vkdecode/src/params_h265.rs create mode 100644 crates/pf-vkdecode/src/pic_h265.rs diff --git a/crates/pf-vkdecode/src/lib.rs b/crates/pf-vkdecode/src/lib.rs index f901ff83..f31add2b 100644 --- a/crates/pf-vkdecode/src/lib.rs +++ b/crates/pf-vkdecode/src/lib.rs @@ -39,6 +39,18 @@ //! caps-gated per queue family: where `queryResultStatusSupport` is absent //! (RADV), verdicts degrade to timeline completion, FFmpeg parity. //! +//! M3 (HEVC) — the CPU half, over [`pf_bitstream::h265`]'s WP-1 planner: +//! +//! - [`params_h265`]: VPS/SPS/PPS into the `StdVideoH265*ParameterSet` structs +//! behind owning wrappers ([`OwnedStdH265Vps`]/[`OwnedStdH265Sps`]/ +//! [`OwnedStdH265Pps`]) — Main/Main10/4:4:4 RExt fidelity carried through, the +//! rest of the envelope rejected typed. +//! - [`pic_h265`]: [`plan_to_vk_h265`], one [`pf_bitstream::h265::AuPlan`] into +//! `StdVideoDecodeH265PictureInfo`/`StdVideoDecodeH265ReferenceInfo` plus the +//! RPS index arrays, slice offsets and slot bindings — over the SAME +//! [`SlotMap`] (H.265's DPB ceiling is H.264's: 16 references + 1 setup). The +//! GPU half (session/images/recording for HEVC) is a later WP. +//! //! Unsafe posture: unlike pf-bitstream (which forbids unsafe outright), this crate //! cannot — the `ash::vk::native` bindgen structs are zero-initialized the way the //! encode side does it (`pf-encode/src/enc/linux/vk_build.rs`), and the GPU half is @@ -52,7 +64,9 @@ pub mod decoder; pub mod device; pub mod images; pub mod params; +pub mod params_h265; pub mod pic; +pub mod pic_h265; pub mod ring; pub mod session; pub mod slots; @@ -93,10 +107,23 @@ pub use params::sps_to_std; pub use params::OwnedStdPps; pub use params::OwnedStdSps; pub use params::ParamsError; +pub use params_h265::fallback_vps_from_sps; +pub use params_h265::pps_to_std_h265; +pub use params_h265::sps_to_std_h265; +pub use params_h265::vps_to_std_h265; +pub use params_h265::H265ParamsError; +pub use params_h265::OwnedStdH265Pps; +pub use params_h265::OwnedStdH265Sps; +pub use params_h265::OwnedStdH265Vps; pub use pic::plan_to_vk; pub use pic::DecodePlanVk; pub use pic::PlanToVkError; pub use pic::VkRef; +pub use pic_h265::plan_to_vk_h265; +pub use pic_h265::DecodePlanVkH265; +pub use pic_h265::PlanToVkH265Error; +pub use pic_h265::VkRefH265; +pub use pic_h265::H265_RPS_LIST_SIZE; pub use ring::RingLayout; pub use session::ParamsAction; pub use session::SessionConfig; diff --git a/crates/pf-vkdecode/src/params_h265.rs b/crates/pf-vkdecode/src/params_h265.rs new file mode 100644 index 00000000..0beca066 --- /dev/null +++ b/crates/pf-vkdecode/src/params_h265.rs @@ -0,0 +1,1813 @@ +//! H.265 parameter-set conversion: the vendored parser's [`Vps`]/[`Sps`]/[`Pps`] +//! into the `StdVideoH265*ParameterSet` structs a Vulkan Video session-parameters +//! object is created from — [`crate::params`] one codec over (M3's CPU half). +//! +//! The Std structs embed raw pointers (`pProfileTierLevel`, `pDecPicBufMgr`, +//! `pScalingLists`, `pShortTermRefPicSet`, `pLongTermRefPicsSps`, ...), so +//! conversion returns OWNING wrappers — the exact aliasing/lifetime contract of +//! [`crate::OwnedStdSps`], restated on [`OwnedStdH265Sps`]. +//! +//! Deliberate skips, mirroring the H.264 module's VUI decision (a DECODE session +//! consumes neither of these — they shape display and rate conformance, not +//! reconstruction): +//! +//! - VUI: `vui_parameters_present_flag` stays 0 and `pSequenceParameterSetVui` +//! stays null. Colour rides [`pf_bitstream::h265::PicturePlan::colour`] into the +//! presenter, per picture, exactly as H.264 does it. +//! - HRD/timing: `vps_timing_info_present_flag` stays 0 and `pHrdParameters` +//! stays null. HRD is buffer-conformance machinery; no decode operation reads it. +//! +//! Short-term RPS candidates are re-encoded in RESOLVED form: the vendored parser +//! has already run the 7.4.8 inter-RPS prediction (equations 7-59..7-66) and +//! stores every SPS candidate as absolute `DeltaPocS0`/`DeltaPocS1` arrays, so +//! each set is declared non-predicted with those derived values encoded back into +//! `delta_poc_sX_minus1` syntax. Equivalent by construction — 7-65/7-66 IS the +//! definition of a set's content, and both the direct and the predicted syntax +//! derive the same arrays — and it is the same "the parser already resolved it" +//! idiom the H.264 module applies to scaling lists. A slice-inline RPS that +//! predicts from an SPS candidate still derives identically in hardware: the +//! derivation consumes only the source set's DeltaPoc arrays and counts, all +//! preserved here (`NumDeltaPocsOfRefRpsIdx` rides the picture info, see +//! [`crate::pic_h265`]). + +use ash::vk::native as hh; +pub use cros_codecs::codec::h265::parser::Pps; +use cros_codecs::codec::h265::parser::ProfileTierLevel; +use cros_codecs::codec::h265::parser::ScalingLists; +use cros_codecs::codec::h265::parser::ShortTermRefPicSet; +pub use cros_codecs::codec::h265::parser::Sps; +pub use cros_codecs::codec::h265::parser::Vps; +use pf_bitstream::h265::Level; + +/// A parameter set that cannot be represented as a StdVideo struct, or that sits +/// outside the punktfunk H.265 decode envelope (4:2:0 and 4:4:4 at 8 or 10 bits, +/// from encoders we control). Hitting one is a stream-integrity failure, not a +/// feature gap — reject-with-error rather than submit a half-truth to a driver. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum H265ParamsError { + /// `general_profile_idc` has no `StdVideoH265ProfileIdc` code point. Vulkan + /// defines Main (1), Main 10 (2), Main Still Picture (3) and Format Range + /// Extensions (4); High Throughput/SCC/scalable profiles land here. + UnmappableProfileIdc(u8), + /// `chroma_format_idc` past 3 — not legal H.265 to begin with. + InvalidChromaFormatIdc(u8), + /// 4:2:2 or monochrome: legal H.265, but no punktfunk host emits it and the + /// client has no output-format plumbing for it — outside the envelope. + UnsupportedChromaFormat(u8), + /// 4:4:4 with `separate_colour_plane_flag`: ChromaArrayType 0 in disguise + /// (three monochrome-coded planes) — no output-format plumbing, and most + /// decode hardware refuses it. + SeparateColourPlanes, + /// Bit depth beyond 8/10, or luma and chroma depths that disagree: no + /// punktfunk output format (NV12/P010 and their 4:4:4 counterparts) can carry + /// it — outside the envelope. + UnsupportedBitDepth { luma_minus8: u8, chroma_minus8: u8 }, + /// SCC palette predictor initializers: `pPredictorPaletteEntries` is the one + /// Std pointer this conversion does not populate (punktfunk hosts emit no SCC + /// coding), and a present-flag over a null pointer would be a half-truth. + PalettePredictorInitializers, + /// `num_short_term_ref_pic_sets` past the spec's 64 (7.4.3.2.1). + TooManyShortTermRpsSets(u8), + /// The SPS declares more short-term RPS candidates than the parser resolved — + /// a corrupt table this conversion refuses to pad. + MissingShortTermRps { index: usize }, + /// An RPS candidate holds more entries on one side than the Std struct's + /// 16-element arrays (7.4.8 bounds both sides by the DPB size, itself <= 16). + RpsEntryOverflow { + set: usize, + negative: u8, + positive: u8, + }, + /// An RPS candidate's derived `DeltaPocS0`/`DeltaPocS1` array is not strictly + /// monotonic (or a step exceeds the 7.4.8 bound of 2^15), so it cannot be + /// re-encoded as `delta_poc_sX_minus1` syntax. Unreachable off the vendored + /// parser; guards directly-constructed inputs. + NonMonotonicRps { set: usize }, + /// `num_long_term_ref_pics_sps` past the spec's 32 (7.4.3.2.1). + TooManyLongTermSpsPics(u8), + /// A syntax value overflows the (narrower) Std field that carries it — e.g. a + /// tile column width past `u16`. The parser bounds everything it reads, so + /// this guards hostile or directly-constructed inputs. + FieldOverflow { field: &'static str, value: i64 }, +} + +impl std::fmt::Display for H265ParamsError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + H265ParamsError::UnmappableProfileIdc(idc) => { + write!( + f, + "general_profile_idc {idc} has no StdVideoH265ProfileIdc code point" + ) + } + H265ParamsError::SeparateColourPlanes => { + write!( + f, + "4:4:4 with separate_colour_plane_flag (ChromaArrayType 0) is \ + outside the punktfunk decode envelope" + ) + } + H265ParamsError::InvalidChromaFormatIdc(idc) => { + write!(f, "invalid chroma_format_idc {idc}") + } + H265ParamsError::UnsupportedChromaFormat(idc) => { + write!( + f, + "chroma_format_idc {idc} is outside the punktfunk decode envelope \ + (4:2:0 and 4:4:4 only)" + ) + } + H265ParamsError::UnsupportedBitDepth { + luma_minus8, + chroma_minus8, + } => { + write!( + f, + "bit depth {}/{} is outside the punktfunk decode envelope \ + (8- and 10-bit, luma == chroma)", + luma_minus8 + 8, + chroma_minus8 + 8 + ) + } + H265ParamsError::PalettePredictorInitializers => { + write!( + f, + "SCC palette predictor initializers are not expressible by this conversion" + ) + } + H265ParamsError::TooManyShortTermRpsSets(n) => { + write!(f, "{n} short-term RPS candidates exceed the spec's 64") + } + H265ParamsError::MissingShortTermRps { index } => { + write!(f, "short-term RPS candidate {index} was never resolved") + } + H265ParamsError::RpsEntryOverflow { + set, + negative, + positive, + } => { + write!( + f, + "short-term RPS candidate {set} holds {negative} negative / {positive} \ + positive entries; the Std arrays hold 16 per side" + ) + } + H265ParamsError::NonMonotonicRps { set } => { + write!( + f, + "short-term RPS candidate {set} has a non-monotonic DeltaPoc array" + ) + } + H265ParamsError::TooManyLongTermSpsPics(n) => { + write!(f, "{n} long-term SPS candidates exceed the spec's 32") + } + H265ParamsError::FieldOverflow { field, value } => { + write!(f, "{field} value {value} overflows its Std field") + } + } + } +} + +impl std::error::Error for H265ParamsError {} + +/// Checked narrowing into a Std field: the parser bounds everything it reads, so +/// an overflow here means hostile or directly-constructed input — fail closed, +/// never truncate (a truncated tile width would decode as garbage, silently). +fn narrow(field: &'static str, value: S) -> Result +where + D: TryFrom, + S: Copy + Into, +{ + D::try_from(value).map_err(|_| H265ParamsError::FieldOverflow { + field, + value: value.into(), + }) +} + +/// The converted VPS plus the heap allocations its embedded pointers target. +/// Ownership contract as [`crate::OwnedStdSps`]: boxed backing, movable wrapper, +/// no mutation, deliberately not `Clone` (re-convert instead). +#[derive(Debug)] +pub struct OwnedStdH265Vps { + std: hh::StdVideoH265VideoParameterSet, + _ptl_backing: Box, + _dpb_backing: Box, +} + +impl OwnedStdH265Vps { + /// The Std struct, valid for as long as `self` lives (do not let a `Copy` of + /// it outlive the wrapper — see [`crate::OwnedStdSps`]). + pub fn std(&self) -> &hh::StdVideoH265VideoParameterSet { + &self.std + } +} + +/// The converted SPS plus the heap allocations its embedded pointers target. +/// +/// Same ownership contract as [`crate::OwnedStdSps`], with five potential +/// pointers: the profile/tier/level and DPB-manager blocks are always present, +/// scaling lists / short-term RPS candidates / long-term SPS candidates only when +/// the stream carries them. `pSequenceParameterSetVui` and +/// `pPredictorPaletteEntries` are null by design (module docs; palette data is +/// rejected, not dropped). +#[derive(Debug)] +pub struct OwnedStdH265Sps { + std: hh::StdVideoH265SequenceParameterSet, + _ptl_backing: Box, + _dpb_backing: Box, + _scaling_backing: Option>, + /// `pShortTermRefPicSet`'s target: `num_short_term_ref_pic_sets` entries. + _st_rps_backing: Option>, + _lt_backing: Option>, +} + +impl OwnedStdH265Sps { + /// The Std struct, valid for as long as `self` lives (see [`crate::OwnedStdSps`]). + pub fn std(&self) -> &hh::StdVideoH265SequenceParameterSet { + &self.std + } +} + +/// The converted PPS plus the scaling-list allocation its `pScalingLists` +/// targets. Same ownership contract as [`crate::OwnedStdSps`]. +#[derive(Debug)] +pub struct OwnedStdH265Pps { + std: hh::StdVideoH265PictureParameterSet, + _scaling_backing: Option>, +} + +impl OwnedStdH265Pps { + /// The Std struct, valid for as long as `self` lives (see [`crate::OwnedStdSps`]). + pub fn std(&self) -> &hh::StdVideoH265PictureParameterSet { + &self.std + } +} + +/// H.265 `general_level_idc` (value-coded: 30 x the level number, Table A.8) to +/// Vulkan's index-coded `StdVideoH265LevelIdc`. The Std code points ascend with +/// the level, so a driver's `maxLevelIdc` gate compares them numerically. +pub(crate) const fn level_to_std(level: Level) -> hh::StdVideoH265LevelIdc { + match level { + Level::L1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_1_0, + Level::L2 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_2_0, + Level::L2_1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_2_1, + Level::L3 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_0, + Level::L3_1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1, + Level::L4 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_0, + Level::L4_1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_1, + Level::L5 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_0, + Level::L5_1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_1, + Level::L5_2 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_2, + Level::L6 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0, + Level::L6_1 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_1, + Level::L6_2 => hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2, + } +} + +/// `general_profile_idc` to `StdVideoH265ProfileIdc` — the code points equal the +/// profile_idc values they name, so recognised ones pass through. +fn profile_to_std(idc: u8) -> Result { + match u32::from(idc) { + p @ 1..=4 => Ok(p), + _ => Err(H265ParamsError::UnmappableProfileIdc(idc)), + } +} + +/// profile_tier_level() to the Std block (always pointer-backed in VPS and SPS). +fn ptl_to_std(ptl: &ProfileTierLevel) -> Result { + // SAFETY: StdVideoH265ProfileTierLevel is a plain-C bindgen struct of a + // bitfield word and two enum ints; all-zero is a valid value for every field. + let mut std: hh::StdVideoH265ProfileTierLevel = unsafe { std::mem::zeroed() }; + std.flags + .set_general_tier_flag(u32::from(ptl.general_tier_flag)); + std.flags + .set_general_progressive_source_flag(u32::from(ptl.general_progressive_source_flag)); + std.flags + .set_general_interlaced_source_flag(u32::from(ptl.general_interlaced_source_flag)); + std.flags + .set_general_non_packed_constraint_flag(u32::from(ptl.general_non_packed_constraint_flag)); + std.flags + .set_general_frame_only_constraint_flag(u32::from(ptl.general_frame_only_constraint_flag)); + std.general_profile_idc = profile_to_std(ptl.general_profile_idc)?; + std.general_level_idc = level_to_std(ptl.general_level_idc); + Ok(std) +} + +/// Pack the parser's scaling lists into the Std layout. +/// +/// The vendored parser has already run 7.4.5 in full — explicit coefficients, +/// matrix-id prediction (equation 7-42) and the Table 7-5/7-6 defaults — so the +/// arrays hold the fully RESOLVED lists (the H.264 module's idiom). Layout notes: +/// +/// - 32x32 (sizeId 3) has exactly TWO lists, at parser matrix ids 0 (intra) and +/// 3 (inter) — 7.4.5's loop steps matrixId by 3 there. The Std array holds them +/// compacted at indices 0 and 1. +/// - The Std DC fields carry the VALUE (`scaling_list_dc_coef_minus8 + 8`, +/// 1..255), not the minus8 syntax element. +fn scaling_lists_to_std( + lists: &ScalingLists, +) -> Result { + // SAFETY: StdVideoH265ScalingLists is a plain-C bindgen struct of byte + // arrays; all-zero is a valid value for every field. + let mut std: hh::StdVideoH265ScalingLists = unsafe { std::mem::zeroed() }; + std.ScalingList4x4 = lists.scaling_list_4x4; + std.ScalingList8x8 = lists.scaling_list_8x8; + std.ScalingList16x16 = lists.scaling_list_16x16; + std.ScalingList32x32 = [lists.scaling_list_32x32[0], lists.scaling_list_32x32[3]]; + for i in 0..6 { + std.ScalingListDCCoef16x16[i] = narrow( + "scaling_list_dc_coef_minus8_16x16 + 8", + i32::from(lists.scaling_list_dc_coef_minus8_16x16[i]) + 8, + )?; + } + for (dst, src) in [0usize, 3].into_iter().enumerate() { + std.ScalingListDCCoef32x32[dst] = narrow( + "scaling_list_dc_coef_minus8_32x32 + 8", + i32::from(lists.scaling_list_dc_coef_minus8_32x32[src]) + 8, + )?; + } + Ok(std) +} + +/// One resolved SPS short-term RPS candidate re-encoded as a non-predicted Std +/// set (module docs: the parser flattened 7.4.8's prediction, so the prediction +/// flags stay 0 and the derived `DeltaPocSX` arrays encode back into +/// `delta_poc_sX_minus1` syntax — `DeltaPocS0` is strictly decreasing negative, +/// `DeltaPocS1` strictly increasing positive, so both step differences are the +/// positive minus1+1 values). +fn st_rps_to_std( + index: usize, + set: &ShortTermRefPicSet, +) -> Result { + let negative = usize::from(set.num_negative_pics); + let positive = usize::from(set.num_positive_pics); + if negative > 16 || positive > 16 { + return Err(H265ParamsError::RpsEntryOverflow { + set: index, + negative: set.num_negative_pics, + positive: set.num_positive_pics, + }); + } + + // SAFETY: StdVideoH265ShortTermRefPicSet is a plain-C bindgen struct of a + // bitfield word, integers and integer arrays; all-zero is valid for every + // field and is exactly the non-predicted baseline (prediction flags 0). + let mut std: hh::StdVideoH265ShortTermRefPicSet = unsafe { std::mem::zeroed() }; + std.num_negative_pics = set.num_negative_pics; + std.num_positive_pics = set.num_positive_pics; + + // 7.4.8: the syntax steps are ue(v)-bounded at 2^15 - 1, so a legal step is + // 1..=2^15; anything else cannot be re-encoded (fn docs). + let mut prev: i64 = 0; + for i in 0..negative { + let cur = i64::from(set.delta_poc_s0[i]); + let step = prev - cur; + if !(1..=32768).contains(&step) { + return Err(H265ParamsError::NonMonotonicRps { set: index }); + } + std.delta_poc_s0_minus1[i] = (step - 1) as u16; + if set.used_by_curr_pic_s0[i] { + std.used_by_curr_pic_s0_flag |= 1 << i; + } + prev = cur; + } + let mut prev: i64 = 0; + for i in 0..positive { + let cur = i64::from(set.delta_poc_s1[i]); + let step = cur - prev; + if !(1..=32768).contains(&step) { + return Err(H265ParamsError::NonMonotonicRps { set: index }); + } + std.delta_poc_s1_minus1[i] = (step - 1) as u16; + if set.used_by_curr_pic_s1[i] { + std.used_by_curr_pic_s1_flag |= 1 << i; + } + prev = cur; + } + Ok(std) +} + +/// The envelope + representability gate every conversion path shares: profile, +/// chroma format and bit depth (struct docs on the error type for the WHY of +/// each). Runs before any allocation so a rejection is cheap and total. +fn check_envelope(sps: &Sps) -> Result<(), H265ParamsError> { + profile_to_std(sps.profile_tier_level.general_profile_idc)?; + if sps.chroma_format_idc > 3 { + return Err(H265ParamsError::InvalidChromaFormatIdc( + sps.chroma_format_idc, + )); + } + if sps.chroma_format_idc == 0 || sps.chroma_format_idc == 2 { + return Err(H265ParamsError::UnsupportedChromaFormat( + sps.chroma_format_idc, + )); + } + // 4:4:4 with separate colour planes is ChromaArrayType 0 in disguise — three + // monochrome-coded planes. No punktfunk output format can carry it and most + // decode hardware refuses it; letting it through would fail later at the + // driver (or worse, map planes wrongly) — the silent class this envelope + // exists to catch. + if sps.chroma_format_idc == 3 && sps.separate_colour_plane_flag { + return Err(H265ParamsError::SeparateColourPlanes); + } + if sps.bit_depth_luma_minus8 != sps.bit_depth_chroma_minus8 + || !matches!(sps.bit_depth_luma_minus8, 0 | 2) + { + return Err(H265ParamsError::UnsupportedBitDepth { + luma_minus8: sps.bit_depth_luma_minus8, + chroma_minus8: sps.bit_depth_chroma_minus8, + }); + } + if sps + .scc_extension + .palette_predictor_initializers_present_flag + { + return Err(H265ParamsError::PalettePredictorInitializers); + } + Ok(()) +} + +/// Convert one VPS into the Std struct (owning wrapper). HRD/timing is skipped by +/// design (module docs); the DPB manager and profile/tier/level blocks ride +/// behind owned pointers. +pub fn vps_to_std_h265(vps: &Vps) -> Result { + let ptl_backing = Box::new(ptl_to_std(&vps.profile_tier_level)?); + + // SAFETY: StdVideoH265DecPicBufMgr is a plain-C bindgen struct of integer + // arrays; all-zero is a valid value for every field. + let mut dpb: hh::StdVideoH265DecPicBufMgr = unsafe { std::mem::zeroed() }; + dpb.max_latency_increase_plus1 = vps.max_latency_increase_plus1; + for i in 0..7 { + // The VPS arrays are u32 in the parser; the spec bounds both syntax + // elements well inside u8 (MaxDpbSize <= 16), so an overflow is corrupt. + dpb.max_dec_pic_buffering_minus1[i] = narrow( + "vps_max_dec_pic_buffering_minus1", + vps.max_dec_pic_buffering_minus1[i], + )?; + dpb.max_num_reorder_pics[i] = + narrow("vps_max_num_reorder_pics", vps.max_num_reorder_pics[i])?; + } + let dpb_backing = Box::new(dpb); + + // SAFETY: StdVideoH265VideoParameterSet is a plain-C bindgen struct of a + // bitfield word, integers and const pointers; all-zero is valid for every + // field (null pointers) and is the baseline the writes below fill. + let mut std: hh::StdVideoH265VideoParameterSet = unsafe { std::mem::zeroed() }; + std.flags + .set_vps_temporal_id_nesting_flag(u32::from(vps.temporal_id_nesting_flag)); + std.flags + .set_vps_sub_layer_ordering_info_present_flag(u32::from( + vps.sub_layer_ordering_info_present_flag, + )); + // vps_timing_info_present_flag and vps_poc_proportional_to_timing_flag stay + // 0 with their fields: the HRD/timing skip (module docs) must be + // self-consistent — a present-flag over a null pHrdParameters would be the + // exact half-truth this module exists to avoid. + std.vps_video_parameter_set_id = vps.video_parameter_set_id; + std.vps_max_sub_layers_minus1 = vps.max_sub_layers_minus1; + std.pDecPicBufMgr = &*dpb_backing; + std.pProfileTierLevel = &*ptl_backing; + + Ok(OwnedStdH265Vps { + std, + _ptl_backing: ptl_backing, + _dpb_backing: dpb_backing, + }) +} + +/// A minimal Std VPS synthesized from the SPS that references it, for streams +/// whose VPS NALU was lost upstream (the parser attaches the VPS to the SPS only +/// when it saw one — `sps.vps` is `None` otherwise). Every stream is REQUIRED to +/// carry a VPS (7.4.2.1), and Vulkan requires the parameters object to hold the +/// VPS the SPS names, so the session layer needs SOMETHING to add; this fallback +/// carries exactly the facts the SPS restates (ids, sub-layer count, +/// profile/tier/level, DPB sizing) — which is also everything a decode session +/// could consult, since the VPS's own additions (timing, layer sets) are all in +/// the deliberate-skip category (module docs). +pub fn fallback_vps_from_sps(sps: &Sps) -> Result { + let ptl_backing = Box::new(ptl_to_std(&sps.profile_tier_level)?); + let dpb_backing = Box::new(sps_dec_pic_buf_mgr(sps)); + + // SAFETY: as in vps_to_std_h265 — all-zero is a valid baseline. + let mut std: hh::StdVideoH265VideoParameterSet = unsafe { std::mem::zeroed() }; + std.vps_video_parameter_set_id = sps.video_parameter_set_id; + std.vps_max_sub_layers_minus1 = sps.max_sub_layers_minus1; + std.flags + .set_vps_temporal_id_nesting_flag(u32::from(sps.temporal_id_nesting_flag)); + std.flags + .set_vps_sub_layer_ordering_info_present_flag(u32::from( + sps.sub_layer_ordering_info_present_flag, + )); + std.pDecPicBufMgr = &*dpb_backing; + std.pProfileTierLevel = &*ptl_backing; + + Ok(OwnedStdH265Vps { + std, + _ptl_backing: ptl_backing, + _dpb_backing: dpb_backing, + }) +} + +/// The SPS's sub-layer ordering arrays as the Std DPB-manager block. Infallible: +/// the parser's SPS arrays are already `u8` where the Std block wants `u8`. +fn sps_dec_pic_buf_mgr(sps: &Sps) -> hh::StdVideoH265DecPicBufMgr { + // SAFETY: plain-C bindgen struct of integer arrays; all-zero is valid. + let mut dpb: hh::StdVideoH265DecPicBufMgr = unsafe { std::mem::zeroed() }; + for i in 0..7 { + dpb.max_latency_increase_plus1[i] = u32::from(sps.max_latency_increase_plus1[i]); + } + dpb.max_dec_pic_buffering_minus1 = sps.max_dec_pic_buffering_minus1; + dpb.max_num_reorder_pics = sps.max_num_reorder_pics; + dpb +} + +/// Convert one SPS into the Std struct (owning wrapper), mapping every field the +/// H.265 decode profile consumes. VUI is skipped by design; SCC palette data is +/// rejected, never dropped (module docs). +pub fn sps_to_std_h265(sps: &Sps) -> Result { + check_envelope(sps)?; + if sps.num_short_term_ref_pic_sets > 64 { + return Err(H265ParamsError::TooManyShortTermRpsSets( + sps.num_short_term_ref_pic_sets, + )); + } + if sps.num_long_term_ref_pics_sps > 32 { + return Err(H265ParamsError::TooManyLongTermSpsPics( + sps.num_long_term_ref_pics_sps, + )); + } + + let ptl_backing = Box::new(ptl_to_std(&sps.profile_tier_level)?); + let dpb_backing = Box::new(sps_dec_pic_buf_mgr(sps)); + + let scaling_backing = sps + .scaling_list_data_present_flag + .then(|| scaling_lists_to_std(&sps.scaling_list).map(Box::new)) + .transpose()?; + + // The COUNT field and the pointer derive from the one condition (the H.264 + // module's stale-count rule): a declared candidate count only ever rides + // over a real array of exactly that many converted sets. + let st_rps_backing = (sps.num_short_term_ref_pic_sets > 0) + .then( + || -> Result, H265ParamsError> { + let count = usize::from(sps.num_short_term_ref_pic_sets); + let mut sets = Vec::with_capacity(count); + for index in 0..count { + let set = sps + .short_term_ref_pic_set + .get(index) + .ok_or(H265ParamsError::MissingShortTermRps { index })?; + sets.push(st_rps_to_std(index, set)?); + } + Ok(sets.into_boxed_slice()) + }, + ) + .transpose()?; + + // Backed whenever the FLAG is set, even with zero SPS candidates: the header + // annotates `pLongTermRefPicsSps` "must be a valid pointer if + // long_term_ref_pics_present_flag is set", and FFmpeg's vulkan_hevc passes it + // unconditionally — a set flag over a null pointer is untested territory in + // every driver. `flag=1, num=0` is not a corner case here: it is exactly the + // punktfunk LTR/RFI-recovery stream shape (slice-signalled long-term pics, + // no SPS candidates — pf-bitstream's own LTR synthesizer emits it), so the + // all-zero struct (the correct content for num=0) must be present. + let lt_backing = sps.long_term_ref_pics_present_flag.then(|| { + // SAFETY: plain-C bindgen struct of a mask and an integer array; + // all-zero is valid. + let mut lt: hh::StdVideoH265LongTermRefPicsSps = unsafe { std::mem::zeroed() }; + for i in 0..usize::from(sps.num_long_term_ref_pics_sps) { + if sps.used_by_curr_pic_lt_sps_flag[i] { + lt.used_by_curr_pic_lt_sps_flag |= 1 << i; + } + } + lt.lt_ref_pic_poc_lsb_sps = sps.lt_ref_pic_poc_lsb_sps; + Box::new(lt) + }); + + // SAFETY: StdVideoH265SequenceParameterSet is a plain-C bindgen struct of a + // bitfield word, integers and const pointers; all-zero is valid for every + // field (null pointers) and is the "everything absent" baseline the field + // writes below build on. Same idiom as the H.264 module. + let mut std: hh::StdVideoH265SequenceParameterSet = unsafe { std::mem::zeroed() }; + + std.flags + .set_sps_temporal_id_nesting_flag(u32::from(sps.temporal_id_nesting_flag)); + std.flags + .set_separate_colour_plane_flag(u32::from(sps.separate_colour_plane_flag)); + std.flags + .set_conformance_window_flag(u32::from(sps.conformance_window_flag)); + std.flags + .set_sps_sub_layer_ordering_info_present_flag(u32::from( + sps.sub_layer_ordering_info_present_flag, + )); + std.flags + .set_scaling_list_enabled_flag(u32::from(sps.scaling_list_enabled_flag)); + // When enabled-but-absent, the driver applies the Table 7-5/7-6 defaults + // itself (7.4.5's inference) — declaring data we did not convert would be + // wrong in exactly the way the null-pointer/flag pairing rules forbid. + std.flags + .set_sps_scaling_list_data_present_flag(u32::from(sps.scaling_list_data_present_flag)); + std.flags + .set_amp_enabled_flag(u32::from(sps.amp_enabled_flag)); + std.flags.set_sample_adaptive_offset_enabled_flag(u32::from( + sps.sample_adaptive_offset_enabled_flag, + )); + std.flags + .set_pcm_enabled_flag(u32::from(sps.pcm_enabled_flag)); + std.flags + .set_pcm_loop_filter_disabled_flag(u32::from(sps.pcm_loop_filter_disabled_flag)); + std.flags + .set_long_term_ref_pics_present_flag(u32::from(sps.long_term_ref_pics_present_flag)); + std.flags + .set_sps_temporal_mvp_enabled_flag(u32::from(sps.temporal_mvp_enabled_flag)); + std.flags.set_strong_intra_smoothing_enabled_flag(u32::from( + sps.strong_intra_smoothing_enabled_flag, + )); + // vui_parameters_present_flag stays 0: decode sessions consume no VUI + // (module docs); colour rides the PicturePlan. + std.flags + .set_sps_extension_present_flag(u32::from(sps.extension_present_flag)); + std.flags + .set_sps_range_extension_flag(u32::from(sps.range_extension_flag)); + let rext = &sps.range_extension; + std.flags + .set_transform_skip_rotation_enabled_flag(u32::from( + rext.transform_skip_rotation_enabled_flag, + )); + std.flags.set_transform_skip_context_enabled_flag(u32::from( + rext.transform_skip_context_enabled_flag, + )); + std.flags + .set_implicit_rdpcm_enabled_flag(u32::from(rext.implicit_rdpcm_enabled_flag)); + std.flags + .set_explicit_rdpcm_enabled_flag(u32::from(rext.explicit_rdpcm_enabled_flag)); + std.flags + .set_extended_precision_processing_flag(u32::from(rext.extended_precision_processing_flag)); + std.flags + .set_intra_smoothing_disabled_flag(u32::from(rext.intra_smoothing_disabled_flag)); + std.flags.set_high_precision_offsets_enabled_flag(u32::from( + rext.high_precision_offsets_enabled_flag, + )); + std.flags + .set_persistent_rice_adaptation_enabled_flag(u32::from( + rext.persistent_rice_adaptation_enabled_flag, + )); + std.flags.set_cabac_bypass_alignment_enabled_flag(u32::from( + rext.cabac_bypass_alignment_enabled_flag, + )); + let scc = &sps.scc_extension; + std.flags + .set_sps_scc_extension_flag(u32::from(sps.scc_extension_flag)); + // Faithful even though the planner's envelope gate rejects SCC + // self-referencing before a plan exists — conversion is not envelope-coupled + // beyond its own representability (the H.264 frame_mbs_only precedent). + std.flags + .set_sps_curr_pic_ref_enabled_flag(u32::from(scc.curr_pic_ref_enabled_flag)); + std.flags + .set_palette_mode_enabled_flag(u32::from(scc.palette_mode_enabled_flag)); + // sps_palette_predictor_initializers_present_flag stays 0: check_envelope + // rejected any SPS that sets it, so flag and (null) pPredictorPaletteEntries + // can never disagree. + std.flags + .set_intra_boundary_filtering_disabled_flag(u32::from( + scc.intra_boundary_filtering_disabled_flag, + )); + + // Chroma format code points equal the chroma_format_idc values (0..3). + std.chroma_format_idc = u32::from(sps.chroma_format_idc); + std.pic_width_in_luma_samples = u32::from(sps.pic_width_in_luma_samples); + std.pic_height_in_luma_samples = u32::from(sps.pic_height_in_luma_samples); + std.sps_video_parameter_set_id = sps.video_parameter_set_id; + std.sps_max_sub_layers_minus1 = sps.max_sub_layers_minus1; + std.sps_seq_parameter_set_id = sps.seq_parameter_set_id; + std.bit_depth_luma_minus8 = sps.bit_depth_luma_minus8; + std.bit_depth_chroma_minus8 = sps.bit_depth_chroma_minus8; + std.log2_max_pic_order_cnt_lsb_minus4 = sps.log2_max_pic_order_cnt_lsb_minus4; + std.log2_min_luma_coding_block_size_minus3 = sps.log2_min_luma_coding_block_size_minus3; + std.log2_diff_max_min_luma_coding_block_size = sps.log2_diff_max_min_luma_coding_block_size; + std.log2_min_luma_transform_block_size_minus2 = sps.log2_min_luma_transform_block_size_minus2; + std.log2_diff_max_min_luma_transform_block_size = + sps.log2_diff_max_min_luma_transform_block_size; + std.max_transform_hierarchy_depth_inter = sps.max_transform_hierarchy_depth_inter; + std.max_transform_hierarchy_depth_intra = sps.max_transform_hierarchy_depth_intra; + std.num_short_term_ref_pic_sets = sps.num_short_term_ref_pic_sets; + std.num_long_term_ref_pics_sps = sps.num_long_term_ref_pics_sps; + std.pcm_sample_bit_depth_luma_minus1 = sps.pcm_sample_bit_depth_luma_minus1; + std.pcm_sample_bit_depth_chroma_minus1 = sps.pcm_sample_bit_depth_chroma_minus1; + std.log2_min_pcm_luma_coding_block_size_minus3 = sps.log2_min_pcm_luma_coding_block_size_minus3; + std.log2_diff_max_min_pcm_luma_coding_block_size = + sps.log2_diff_max_min_pcm_luma_coding_block_size; + std.palette_max_size = scc.palette_max_size; + std.delta_palette_max_predictor_size = scc.delta_palette_max_predictor_size; + std.motion_vector_resolution_control_idc = scc.motion_vector_resolution_control_idc; + std.sps_num_palette_predictor_initializers_minus1 = + scc.num_palette_predictor_initializer_minus1; + std.conf_win_left_offset = sps.conf_win_left_offset; + std.conf_win_right_offset = sps.conf_win_right_offset; + std.conf_win_top_offset = sps.conf_win_top_offset; + std.conf_win_bottom_offset = sps.conf_win_bottom_offset; + + std.pProfileTierLevel = &*ptl_backing; + std.pDecPicBufMgr = &*dpb_backing; + if let Some(backing) = &scaling_backing { + std.pScalingLists = &**backing; + } + if let Some(backing) = &st_rps_backing { + std.pShortTermRefPicSet = backing.as_ptr(); + } + if let Some(backing) = <_backing { + std.pLongTermRefPicsSps = &**backing; + } + // pSequenceParameterSetVui and pPredictorPaletteEntries stay null (module + // docs / check_envelope). + + Ok(OwnedStdH265Sps { + std, + _ptl_backing: ptl_backing, + _dpb_backing: dpb_backing, + _scaling_backing: scaling_backing, + _st_rps_backing: st_rps_backing, + _lt_backing: lt_backing, + }) +} + +/// Convert one PPS into the Std struct (owning wrapper), mapping every field the +/// H.265 decode profile consumes. SCC palette data is rejected, never dropped; +/// every narrower Std field is checked, never truncated. +pub fn pps_to_std_h265(pps: &Pps) -> Result { + if pps + .scc_extension + .palette_predictor_initializers_present_flag + { + return Err(H265ParamsError::PalettePredictorInitializers); + } + + let scaling_backing = pps + .scaling_list_data_present_flag + .then(|| scaling_lists_to_std(&pps.scaling_list).map(Box::new)) + .transpose()?; + + // SAFETY: StdVideoH265PictureParameterSet is a plain-C bindgen struct of a + // bitfield word, integers, integer arrays and const pointers; all-zero is + // valid for every field (null pointers) and is the baseline the writes fill. + let mut std: hh::StdVideoH265PictureParameterSet = unsafe { std::mem::zeroed() }; + + std.flags + .set_dependent_slice_segments_enabled_flag(u32::from( + pps.dependent_slice_segments_enabled_flag, + )); + std.flags + .set_output_flag_present_flag(u32::from(pps.output_flag_present_flag)); + std.flags + .set_sign_data_hiding_enabled_flag(u32::from(pps.sign_data_hiding_enabled_flag)); + std.flags + .set_cabac_init_present_flag(u32::from(pps.cabac_init_present_flag)); + std.flags + .set_constrained_intra_pred_flag(u32::from(pps.constrained_intra_pred_flag)); + std.flags + .set_transform_skip_enabled_flag(u32::from(pps.transform_skip_enabled_flag)); + std.flags + .set_cu_qp_delta_enabled_flag(u32::from(pps.cu_qp_delta_enabled_flag)); + std.flags + .set_pps_slice_chroma_qp_offsets_present_flag(u32::from( + pps.slice_chroma_qp_offsets_present_flag, + )); + std.flags + .set_weighted_pred_flag(u32::from(pps.weighted_pred_flag)); + std.flags + .set_weighted_bipred_flag(u32::from(pps.weighted_bipred_flag)); + std.flags + .set_transquant_bypass_enabled_flag(u32::from(pps.transquant_bypass_enabled_flag)); + std.flags + .set_tiles_enabled_flag(u32::from(pps.tiles_enabled_flag)); + std.flags + .set_entropy_coding_sync_enabled_flag(u32::from(pps.entropy_coding_sync_enabled_flag)); + std.flags + .set_uniform_spacing_flag(u32::from(pps.uniform_spacing_flag)); + std.flags + .set_loop_filter_across_tiles_enabled_flag(u32::from( + pps.loop_filter_across_tiles_enabled_flag, + )); + std.flags + .set_pps_loop_filter_across_slices_enabled_flag(u32::from( + pps.loop_filter_across_slices_enabled_flag, + )); + std.flags + .set_deblocking_filter_control_present_flag(u32::from( + pps.deblocking_filter_control_present_flag, + )); + std.flags + .set_deblocking_filter_override_enabled_flag(u32::from( + pps.deblocking_filter_override_enabled_flag, + )); + std.flags + .set_pps_deblocking_filter_disabled_flag(u32::from(pps.deblocking_filter_disabled_flag)); + std.flags + .set_pps_scaling_list_data_present_flag(u32::from(pps.scaling_list_data_present_flag)); + std.flags + .set_lists_modification_present_flag(u32::from(pps.lists_modification_present_flag)); + std.flags + .set_slice_segment_header_extension_present_flag(u32::from( + pps.slice_segment_header_extension_present_flag, + )); + std.flags + .set_pps_extension_present_flag(u32::from(pps.extension_present_flag)); + let rext = &pps.range_extension; + std.flags + .set_cross_component_prediction_enabled_flag(u32::from( + rext.cross_component_prediction_enabled_flag, + )); + std.flags + .set_chroma_qp_offset_list_enabled_flag(u32::from(rext.chroma_qp_offset_list_enabled_flag)); + let scc = &pps.scc_extension; + std.flags + .set_pps_curr_pic_ref_enabled_flag(u32::from(scc.curr_pic_ref_enabled_flag)); + std.flags + .set_residual_adaptive_colour_transform_enabled_flag(u32::from( + scc.residual_adaptive_colour_transform_enabled_flag, + )); + std.flags + .set_pps_slice_act_qp_offsets_present_flag(u32::from( + scc.slice_act_qp_offsets_present_flag, + )); + // pps_palette_predictor_initializers_present_flag stays 0 (rejected above). + std.flags + .set_monochrome_palette_flag(u32::from(scc.monochrome_palette_flag)); + std.flags + .set_pps_range_extension_flag(u32::from(pps.range_extension_flag)); + + std.pps_pic_parameter_set_id = pps.pic_parameter_set_id; + std.pps_seq_parameter_set_id = pps.seq_parameter_set_id; + // The VPS id the Std PPS names is the one its OWN SPS references — the + // parser resolved that chain at parse time. + std.sps_video_parameter_set_id = pps.sps.video_parameter_set_id; + std.num_extra_slice_header_bits = pps.num_extra_slice_header_bits; + std.num_ref_idx_l0_default_active_minus1 = pps.num_ref_idx_l0_default_active_minus1; + std.num_ref_idx_l1_default_active_minus1 = pps.num_ref_idx_l1_default_active_minus1; + std.init_qp_minus26 = pps.init_qp_minus26; + std.diff_cu_qp_delta_depth = pps.diff_cu_qp_delta_depth; + std.pps_cb_qp_offset = pps.cb_qp_offset; + std.pps_cr_qp_offset = pps.cr_qp_offset; + std.pps_beta_offset_div2 = pps.beta_offset_div2; + std.pps_tc_offset_div2 = pps.tc_offset_div2; + std.log2_parallel_merge_level_minus2 = pps.log2_parallel_merge_level_minus2; + std.log2_max_transform_skip_block_size_minus2 = narrow( + "log2_max_transform_skip_block_size_minus2", + rext.log2_max_transform_skip_block_size_minus2, + )?; + std.diff_cu_chroma_qp_offset_depth = narrow( + "diff_cu_chroma_qp_offset_depth", + rext.diff_cu_chroma_qp_offset_depth, + )?; + std.chroma_qp_offset_list_len_minus1 = narrow( + "chroma_qp_offset_list_len_minus1", + rext.chroma_qp_offset_list_len_minus1, + )?; + for i in 0..6 { + std.cb_qp_offset_list[i] = narrow("cb_qp_offset_list", rext.cb_qp_offset_list[i])?; + std.cr_qp_offset_list[i] = narrow("cr_qp_offset_list", rext.cr_qp_offset_list[i])?; + } + std.log2_sao_offset_scale_luma = narrow( + "log2_sao_offset_scale_luma", + rext.log2_sao_offset_scale_luma, + )?; + std.log2_sao_offset_scale_chroma = narrow( + "log2_sao_offset_scale_chroma", + rext.log2_sao_offset_scale_chroma, + )?; + std.pps_act_y_qp_offset_plus5 = scc.act_y_qp_offset_plus5; + std.pps_act_cb_qp_offset_plus5 = scc.act_cb_qp_offset_plus5; + std.pps_act_cr_qp_offset_plus3 = scc.act_cr_qp_offset_plus3; + std.pps_num_palette_predictor_initializers = scc.num_palette_predictor_initializers; + std.luma_bit_depth_entry_minus8 = scc.luma_bit_depth_entry_minus8; + std.chroma_bit_depth_entry_minus8 = scc.chroma_bit_depth_entry_minus8; + std.num_tile_columns_minus1 = pps.num_tile_columns_minus1; + std.num_tile_rows_minus1 = pps.num_tile_rows_minus1; + for i in 0..19 { + std.column_width_minus1[i] = narrow("column_width_minus1", pps.column_width_minus1[i])?; + } + for i in 0..21 { + std.row_height_minus1[i] = narrow("row_height_minus1", pps.row_height_minus1[i])?; + } + + if let Some(backing) = &scaling_backing { + std.pScalingLists = &**backing; + } + // pPredictorPaletteEntries stays null (rejected above). + + Ok(OwnedStdH265Pps { + std, + _scaling_backing: scaling_backing, + }) +} + +#[cfg(test)] +mod tests { + use cros_codecs::codec::h265::parser::PpsRangeExtension; + use cros_codecs::codec::h265::parser::PpsSccExtension; + use cros_codecs::codec::h265::parser::ProfileTierLevel; + use cros_codecs::codec::h265::parser::SpsRangeExtension; + use std::rc::Rc; + + use super::*; + + /// An SPS exercising every mapped field with distinct values. Flags carry a + /// deliberate mixed pattern asserted bit-for-bit below; + /// `vui_parameters_present_flag` is true at the SOURCE precisely because the + /// conversion must NOT copy it (the VUI skip). + fn full_sps() -> Sps { + Sps { + video_parameter_set_id: 2, + max_sub_layers_minus1: 1, + temporal_id_nesting_flag: true, + profile_tier_level: ProfileTierLevel { + general_profile_idc: 2, // Main 10 + general_tier_flag: true, + general_progressive_source_flag: true, + general_interlaced_source_flag: false, + general_non_packed_constraint_flag: true, + general_frame_only_constraint_flag: false, + general_level_idc: Level::L4_1, + ..Default::default() + }, + seq_parameter_set_id: 5, + chroma_format_idc: 1, + pic_width_in_luma_samples: 1920, + pic_height_in_luma_samples: 1080, + conformance_window_flag: true, + conf_win_left_offset: 1, + conf_win_right_offset: 2, + conf_win_top_offset: 3, + conf_win_bottom_offset: 4, + bit_depth_luma_minus8: 2, + bit_depth_chroma_minus8: 2, + log2_max_pic_order_cnt_lsb_minus4: 6, + sub_layer_ordering_info_present_flag: true, + max_dec_pic_buffering_minus1: [5, 6, 0, 0, 0, 0, 0], + max_num_reorder_pics: [1, 2, 0, 0, 0, 0, 0], + max_latency_increase_plus1: [7, 8, 0, 0, 0, 0, 0], + log2_min_luma_coding_block_size_minus3: 1, + log2_diff_max_min_luma_coding_block_size: 2, + log2_min_luma_transform_block_size_minus2: 1, + log2_diff_max_min_luma_transform_block_size: 3, + max_transform_hierarchy_depth_inter: 2, + max_transform_hierarchy_depth_intra: 3, + scaling_list_enabled_flag: true, + scaling_list_data_present_flag: false, + amp_enabled_flag: true, + sample_adaptive_offset_enabled_flag: false, + pcm_enabled_flag: true, + pcm_sample_bit_depth_luma_minus1: 7, + pcm_sample_bit_depth_chroma_minus1: 9, + log2_min_pcm_luma_coding_block_size_minus3: 1, + log2_diff_max_min_pcm_luma_coding_block_size: 2, + pcm_loop_filter_disabled_flag: true, + num_short_term_ref_pic_sets: 0, + long_term_ref_pics_present_flag: false, + temporal_mvp_enabled_flag: true, + strong_intra_smoothing_enabled_flag: false, + vui_parameters_present_flag: true, + extension_present_flag: true, + range_extension_flag: true, + range_extension: SpsRangeExtension { + transform_skip_rotation_enabled_flag: true, + transform_skip_context_enabled_flag: false, + implicit_rdpcm_enabled_flag: true, + explicit_rdpcm_enabled_flag: false, + extended_precision_processing_flag: true, + intra_smoothing_disabled_flag: false, + high_precision_offsets_enabled_flag: true, + persistent_rice_adaptation_enabled_flag: false, + cabac_bypass_alignment_enabled_flag: true, + }, + ..Default::default() + } + } + + /// A PPS over `sps` exercising every mapped field with distinct values — + /// the vendored `Pps` derives no `Default`, so the literal is spelled out + /// once here (the h264 `full_pps` idiom). + fn full_pps(sps: Sps) -> Pps { + Pps { + pic_parameter_set_id: 3, + seq_parameter_set_id: 5, + dependent_slice_segments_enabled_flag: true, + output_flag_present_flag: false, + num_extra_slice_header_bits: 2, + sign_data_hiding_enabled_flag: true, + cabac_init_present_flag: false, + num_ref_idx_l0_default_active_minus1: 2, + num_ref_idx_l1_default_active_minus1: 1, + init_qp_minus26: -3, + constrained_intra_pred_flag: true, + transform_skip_enabled_flag: false, + cu_qp_delta_enabled_flag: true, + diff_cu_qp_delta_depth: 2, + cb_qp_offset: -4, + cr_qp_offset: 5, + slice_chroma_qp_offsets_present_flag: false, + weighted_pred_flag: true, + weighted_bipred_flag: false, + transquant_bypass_enabled_flag: true, + tiles_enabled_flag: true, + entropy_coding_sync_enabled_flag: false, + num_tile_columns_minus1: 1, + num_tile_rows_minus1: 2, + uniform_spacing_flag: false, + column_width_minus1: { + let mut w = [0u32; 19]; + w[0] = 17; + w[1] = 12; + w + }, + row_height_minus1: { + let mut h = [0u32; 21]; + h[0] = 9; + h[1] = 8; + h[2] = 16; + h + }, + loop_filter_across_tiles_enabled_flag: true, + loop_filter_across_slices_enabled_flag: false, + deblocking_filter_control_present_flag: true, + deblocking_filter_override_enabled_flag: false, + deblocking_filter_disabled_flag: true, + beta_offset_div2: -2, + tc_offset_div2: 3, + scaling_list_data_present_flag: false, + scaling_list: Default::default(), + lists_modification_present_flag: true, + log2_parallel_merge_level_minus2: 1, + slice_segment_header_extension_present_flag: false, + extension_present_flag: true, + range_extension_flag: true, + range_extension: PpsRangeExtension { + log2_max_transform_skip_block_size_minus2: 2, + cross_component_prediction_enabled_flag: true, + chroma_qp_offset_list_enabled_flag: true, + diff_cu_chroma_qp_offset_depth: 1, + chroma_qp_offset_list_len_minus1: 1, + cb_qp_offset_list: [1, -2, 0, 0, 0, 0], + cr_qp_offset_list: [-3, 4, 0, 0, 0, 0], + log2_sao_offset_scale_luma: 1, + log2_sao_offset_scale_chroma: 2, + }, + scc_extension_flag: false, + scc_extension: PpsSccExtension::default(), + qp_bd_offset_y: 0, + sps: Rc::new(sps), + } + } + + #[test] + fn every_mapped_sps_field_and_flag_round_trips_exactly() { + let sps = full_sps(); + let owned = sps_to_std_h265(&sps).unwrap(); + let std = owned.std(); + + // The fixture's mixed pattern, bit for bit. + assert_eq!(std.flags.sps_temporal_id_nesting_flag(), 1); + assert_eq!(std.flags.separate_colour_plane_flag(), 0); + assert_eq!(std.flags.conformance_window_flag(), 1); + assert_eq!(std.flags.sps_sub_layer_ordering_info_present_flag(), 1); + assert_eq!(std.flags.scaling_list_enabled_flag(), 1); + assert_eq!(std.flags.sps_scaling_list_data_present_flag(), 0); + assert_eq!(std.flags.amp_enabled_flag(), 1); + assert_eq!(std.flags.sample_adaptive_offset_enabled_flag(), 0); + assert_eq!(std.flags.pcm_enabled_flag(), 1); + assert_eq!(std.flags.pcm_loop_filter_disabled_flag(), 1); + assert_eq!(std.flags.long_term_ref_pics_present_flag(), 0); + assert_eq!(std.flags.sps_temporal_mvp_enabled_flag(), 1); + assert_eq!(std.flags.strong_intra_smoothing_enabled_flag(), 0); + assert_eq!( + std.flags.vui_parameters_present_flag(), + 0, + "true at the source, skipped by design" + ); + assert_eq!(std.flags.sps_extension_present_flag(), 1); + assert_eq!(std.flags.sps_range_extension_flag(), 1); + // The range-extension flags, alternating T/F per the fixture. + assert_eq!(std.flags.transform_skip_rotation_enabled_flag(), 1); + assert_eq!(std.flags.transform_skip_context_enabled_flag(), 0); + assert_eq!(std.flags.implicit_rdpcm_enabled_flag(), 1); + assert_eq!(std.flags.explicit_rdpcm_enabled_flag(), 0); + assert_eq!(std.flags.extended_precision_processing_flag(), 1); + assert_eq!(std.flags.intra_smoothing_disabled_flag(), 0); + assert_eq!(std.flags.high_precision_offsets_enabled_flag(), 1); + assert_eq!(std.flags.persistent_rice_adaptation_enabled_flag(), 0); + assert_eq!(std.flags.cabac_bypass_alignment_enabled_flag(), 1); + assert_eq!(std.flags.sps_scc_extension_flag(), 0); + assert_eq!(std.flags.sps_curr_pic_ref_enabled_flag(), 0); + assert_eq!(std.flags.palette_mode_enabled_flag(), 0); + assert_eq!( + std.flags.sps_palette_predictor_initializers_present_flag(), + 0 + ); + assert_eq!(std.flags.intra_boundary_filtering_disabled_flag(), 0); + + assert_eq!( + std.chroma_format_idc, + hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_420 + ); + assert_eq!(std.pic_width_in_luma_samples, 1920); + assert_eq!(std.pic_height_in_luma_samples, 1080); + assert_eq!(std.sps_video_parameter_set_id, 2); + assert_eq!(std.sps_max_sub_layers_minus1, 1); + assert_eq!(std.sps_seq_parameter_set_id, 5); + assert_eq!(std.bit_depth_luma_minus8, 2); + assert_eq!(std.bit_depth_chroma_minus8, 2); + assert_eq!(std.log2_max_pic_order_cnt_lsb_minus4, 6); + assert_eq!(std.log2_min_luma_coding_block_size_minus3, 1); + assert_eq!(std.log2_diff_max_min_luma_coding_block_size, 2); + assert_eq!(std.log2_min_luma_transform_block_size_minus2, 1); + assert_eq!(std.log2_diff_max_min_luma_transform_block_size, 3); + assert_eq!(std.max_transform_hierarchy_depth_inter, 2); + assert_eq!(std.max_transform_hierarchy_depth_intra, 3); + assert_eq!(std.num_short_term_ref_pic_sets, 0); + assert_eq!(std.num_long_term_ref_pics_sps, 0); + assert_eq!(std.pcm_sample_bit_depth_luma_minus1, 7); + assert_eq!( + std.pcm_sample_bit_depth_chroma_minus1, 9, + "distinct from luma" + ); + assert_eq!(std.log2_min_pcm_luma_coding_block_size_minus3, 1); + assert_eq!(std.log2_diff_max_min_pcm_luma_coding_block_size, 2); + assert_eq!(std.conf_win_left_offset, 1); + assert_eq!(std.conf_win_right_offset, 2); + assert_eq!(std.conf_win_top_offset, 3); + assert_eq!(std.conf_win_bottom_offset, 4); + + // The always-present pointer-backed blocks. + assert!(!std.pProfileTierLevel.is_null()); + // SAFETY: pProfileTierLevel targets `owned`'s boxed backing, alive here. + let ptl = unsafe { &*std.pProfileTierLevel }; + assert_eq!(ptl.flags.general_tier_flag(), 1); + assert_eq!(ptl.flags.general_progressive_source_flag(), 1); + assert_eq!(ptl.flags.general_interlaced_source_flag(), 0); + assert_eq!(ptl.flags.general_non_packed_constraint_flag(), 1); + assert_eq!(ptl.flags.general_frame_only_constraint_flag(), 0); + assert_eq!( + ptl.general_profile_idc, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10 + ); + assert_eq!( + ptl.general_level_idc, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_1 + ); + assert!(!std.pDecPicBufMgr.is_null()); + // SAFETY: pDecPicBufMgr targets `owned`'s boxed backing, alive here. + let dpb = unsafe { &*std.pDecPicBufMgr }; + assert_eq!(&dpb.max_dec_pic_buffering_minus1[..2], &[5, 6]); + assert_eq!(&dpb.max_num_reorder_pics[..2], &[1, 2]); + assert_eq!(&dpb.max_latency_increase_plus1[..2], &[7, 8]); + + // The absent-by-content and absent-by-design pointers. + assert!( + std.pScalingLists.is_null(), + "enabled but data-absent: driver defaults" + ); + assert!(std.pShortTermRefPicSet.is_null()); + assert!(std.pLongTermRefPicsSps.is_null()); + assert!(std.pSequenceParameterSetVui.is_null()); + assert!(std.pPredictorPaletteEntries.is_null()); + } + + #[test] + fn profile_and_level_code_points_map_or_reject() { + for (idc, expect) in [ + ( + 1u8, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN, + ), + ( + 2, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10, + ), + ( + 3, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_STILL_PICTURE, + ), + ( + 4, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_FORMAT_RANGE_EXTENSIONS, + ), + ] { + assert_eq!(profile_to_std(idc).unwrap(), expect); + } + // High Throughput (5) and SCC (9) exist on the wire but not in Vulkan. + for idc in [0u8, 5, 9, 11] { + assert_eq!( + profile_to_std(idc).unwrap_err(), + H265ParamsError::UnmappableProfileIdc(idc) + ); + } + + // Every Table A.8 level maps to its ascending index-coded point. + let pairs = [ + ( + Level::L1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_1_0, + ), + ( + Level::L2, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_2_0, + ), + ( + Level::L2_1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_2_1, + ), + ( + Level::L3, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_0, + ), + ( + Level::L3_1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_3_1, + ), + ( + Level::L4, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_0, + ), + ( + Level::L4_1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_1, + ), + ( + Level::L5, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_0, + ), + ( + Level::L5_1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_1, + ), + ( + Level::L5_2, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_5_2, + ), + ( + Level::L6, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_0, + ), + ( + Level::L6_1, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_1, + ), + ( + Level::L6_2, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_6_2, + ), + ]; + let mut prev = None; + for (level, expect) in pairs { + assert_eq!(level_to_std(level), expect); + if let Some(prev) = prev { + assert!(expect > prev, "code points must ascend for the caps gate"); + } + prev = Some(expect); + } + } + + #[test] + fn the_owned_backings_survive_moving_the_wrapper() { + // Box the wrapper AFTER conversion: a move relocating the wrapper itself + // must not invalidate its pointers, because the backing is heap-pinned + // (the h264 POC-offset test, one codec over). + let owned = Box::new(sps_to_std_h265(&full_sps()).unwrap()); + let std = owned.std(); + // SAFETY: both pointers target `owned`'s boxed backings, alive in scope. + let (ptl, dpb) = unsafe { (&*std.pProfileTierLevel, &*std.pDecPicBufMgr) }; + assert_eq!( + ptl.general_profile_idc, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10 + ); + assert_eq!(dpb.max_dec_pic_buffering_minus1[0], 5); + } + + #[test] + fn sps_scaling_lists_convert_verbatim_including_the_32x32_pair_and_dc_values() { + let mut sps = full_sps(); + sps.scaling_list_data_present_flag = true; + // Distinct fill bytes per list; the 32x32 lists live at PARSER matrix + // ids 0 and 3 (7.4.5 steps matrixId by 3 at sizeId 3) and must land at + // Std indices 0 and 1. + sps.scaling_list.scaling_list_4x4 = std::array::from_fn(|i| [10 + i as u8; 16]); + sps.scaling_list.scaling_list_8x8 = std::array::from_fn(|i| [20 + i as u8; 64]); + sps.scaling_list.scaling_list_16x16 = std::array::from_fn(|i| [30 + i as u8; 64]); + sps.scaling_list.scaling_list_32x32 = std::array::from_fn(|i| [40 + i as u8; 64]); + sps.scaling_list.scaling_list_dc_coef_minus8_16x16 = [-7, 0, 8, 100, 200, 247]; + sps.scaling_list.scaling_list_dc_coef_minus8_32x32 = [42, 0, 0, 99, 0, 0]; + + let owned = sps_to_std_h265(&sps).unwrap(); + let std = owned.std(); + assert_eq!(std.flags.sps_scaling_list_data_present_flag(), 1); + assert!(!std.pScalingLists.is_null()); + // SAFETY: pScalingLists targets `owned`'s boxed backing, alive here. + let lists = unsafe { &*std.pScalingLists }; + for i in 0..6 { + assert_eq!(lists.ScalingList4x4[i], [10 + i as u8; 16], "4x4 list {i}"); + assert_eq!(lists.ScalingList8x8[i], [20 + i as u8; 64], "8x8 list {i}"); + assert_eq!( + lists.ScalingList16x16[i], + [30 + i as u8; 64], + "16x16 list {i}" + ); + } + assert_eq!( + lists.ScalingList32x32[0], [40; 64], + "intra = parser index 0" + ); + assert_eq!( + lists.ScalingList32x32[1], [43; 64], + "inter = parser index 3" + ); + // The Std DC fields carry the +8 VALUE, not the minus8 syntax element. + assert_eq!(lists.ScalingListDCCoef16x16, [1, 8, 16, 108, 208, 255]); + assert_eq!(lists.ScalingListDCCoef32x32, [50, 107]); + } + + #[test] + fn short_term_rps_candidates_reencode_to_the_std_syntax_exactly() { + let mut sps = full_sps(); + sps.num_short_term_ref_pic_sets = 2; + // Candidate 0: DeltaPocS0 = [-1, -3] (steps 1, 2), DeltaPocS1 = [2] + // (step 2), with mixed used_by flags. + let mut set0 = ShortTermRefPicSet { + num_negative_pics: 2, + num_positive_pics: 1, + ..Default::default() + }; + set0.delta_poc_s0[0] = -1; + set0.delta_poc_s0[1] = -3; + set0.used_by_curr_pic_s0[0] = true; + set0.used_by_curr_pic_s0[1] = false; + set0.delta_poc_s1[0] = 2; + set0.used_by_curr_pic_s1[0] = true; + // Candidate 1: as the parser leaves a PREDICTED set — resolved arrays + // with the prediction syntax still recorded. The conversion must emit + // the resolved non-predicted form, ignoring the prediction fields. + let mut set1 = ShortTermRefPicSet { + inter_ref_pic_set_prediction_flag: true, + delta_idx_minus1: 0, + abs_delta_rps_minus1: 0, + num_negative_pics: 1, + ..Default::default() + }; + set1.delta_poc_s0[0] = -2; + set1.used_by_curr_pic_s0[0] = true; + sps.short_term_ref_pic_set = vec![set0, set1]; + + let owned = sps_to_std_h265(&sps).unwrap(); + let std = owned.std(); + assert_eq!(std.num_short_term_ref_pic_sets, 2); + assert!(!std.pShortTermRefPicSet.is_null()); + // SAFETY: pShortTermRefPicSet targets `owned`'s boxed slice of exactly + // num_short_term_ref_pic_sets entries, alive for this whole scope. + let sets = unsafe { std::slice::from_raw_parts(std.pShortTermRefPicSet, 2) }; + + assert_eq!(sets[0].num_negative_pics, 2); + assert_eq!(sets[0].num_positive_pics, 1); + assert_eq!( + &sets[0].delta_poc_s0_minus1[..2], + &[0, 1], + "steps 1 and 2, minus 1" + ); + assert_eq!(sets[0].delta_poc_s1_minus1[0], 1, "step 2, minus 1"); + assert_eq!(sets[0].used_by_curr_pic_s0_flag, 0b01); + assert_eq!(sets[0].used_by_curr_pic_s1_flag, 0b1); + + assert_eq!( + sets[1].flags.inter_ref_pic_set_prediction_flag(), + 0, + "resolved form: the prediction is flattened, never re-declared" + ); + assert_eq!(sets[1].delta_idx_minus1, 0); + assert_eq!(sets[1].num_negative_pics, 1); + assert_eq!(sets[1].delta_poc_s0_minus1[0], 1); + } + + #[test] + fn long_term_sps_candidates_ride_with_mask_and_poc_lsbs() { + let mut sps = full_sps(); + sps.long_term_ref_pics_present_flag = true; + sps.num_long_term_ref_pics_sps = 3; + sps.lt_ref_pic_poc_lsb_sps[0] = 11; + sps.lt_ref_pic_poc_lsb_sps[1] = 22; + sps.lt_ref_pic_poc_lsb_sps[2] = 33; + sps.used_by_curr_pic_lt_sps_flag[0] = true; + sps.used_by_curr_pic_lt_sps_flag[2] = true; + + let owned = sps_to_std_h265(&sps).unwrap(); + let std = owned.std(); + assert_eq!(std.flags.long_term_ref_pics_present_flag(), 1); + assert_eq!(std.num_long_term_ref_pics_sps, 3); + assert!(!std.pLongTermRefPicsSps.is_null()); + // SAFETY: pLongTermRefPicsSps targets `owned`'s boxed backing, alive here. + let lt = unsafe { &*std.pLongTermRefPicsSps }; + assert_eq!(lt.used_by_curr_pic_lt_sps_flag, 0b101); + assert_eq!(<.lt_ref_pic_poc_lsb_sps[..3], &[11, 22, 33]); + } + + /// `flag=1, num=0` is not a corner case: it is the punktfunk LTR/RFI + /// recovery stream shape (slice-signalled long-term pics, zero SPS + /// candidates — pf-bitstream's LTR synthesizer emits exactly this). The + /// header requires a valid `pLongTermRefPicsSps` whenever the flag is set; + /// a set flag over a null pointer is untested territory in every driver. + #[test] + fn long_term_flag_without_sps_candidates_still_backs_the_pointer() { + let mut sps = full_sps(); + sps.long_term_ref_pics_present_flag = true; + sps.num_long_term_ref_pics_sps = 0; + + let owned = sps_to_std_h265(&sps).unwrap(); + let std = owned.std(); + assert_eq!(std.flags.long_term_ref_pics_present_flag(), 1); + assert_eq!(std.num_long_term_ref_pics_sps, 0); + assert!(!std.pLongTermRefPicsSps.is_null()); + // SAFETY: pLongTermRefPicsSps targets `owned`'s boxed backing, alive here. + let lt = unsafe { &*std.pLongTermRefPicsSps }; + assert_eq!(lt.used_by_curr_pic_lt_sps_flag, 0); + assert!(lt.lt_ref_pic_poc_lsb_sps.iter().all(|&lsb| lsb == 0)); + } + + #[test] + fn envelope_rejections_fail_closed_instead_of_approximating() { + // 4:2:2 — legal H.265, outside the punktfunk envelope. + let mut sps = full_sps(); + sps.chroma_format_idc = 2; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::UnsupportedChromaFormat(2) + ); + // Monochrome likewise. + sps.chroma_format_idc = 0; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::UnsupportedChromaFormat(0) + ); + // 4:4:4 with separate colour planes = ChromaArrayType 0 in disguise. + sps.chroma_format_idc = 3; + sps.separate_colour_plane_flag = true; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::SeparateColourPlanes + ); + sps.separate_colour_plane_flag = false; + // Past 3 is not legal H.265 at all. + sps.chroma_format_idc = 4; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::InvalidChromaFormatIdc(4) + ); + + // 12-bit and mismatched depths: no punktfunk output format carries them. + let mut sps = full_sps(); + sps.bit_depth_luma_minus8 = 4; + sps.bit_depth_chroma_minus8 = 4; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::UnsupportedBitDepth { + luma_minus8: 4, + chroma_minus8: 4 + } + ); + let mut sps = full_sps(); + sps.bit_depth_chroma_minus8 = 0; + assert!(matches!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::UnsupportedBitDepth { .. } + )); + + // 4:4:4 at 8 and 10 bits IS in the envelope (RExt profile). + let mut sps = full_sps(); + sps.profile_tier_level.general_profile_idc = 4; + sps.chroma_format_idc = 3; + sps.bit_depth_luma_minus8 = 0; + sps.bit_depth_chroma_minus8 = 0; + let owned = sps_to_std_h265(&sps).unwrap(); + assert_eq!( + owned.std().chroma_format_idc, + hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_444 + ); + + // An unmappable profile. + let mut sps = full_sps(); + sps.profile_tier_level.general_profile_idc = 9; // SCC + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::UnmappableProfileIdc(9) + ); + + // SCC palette predictor initializers: the one pointer we refuse to fake, + // on both parameter sets. + let mut sps = full_sps(); + sps.scc_extension + .palette_predictor_initializers_present_flag = true; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::PalettePredictorInitializers + ); + let mut pps = full_pps(full_sps()); + pps.scc_extension + .palette_predictor_initializers_present_flag = true; + assert_eq!( + pps_to_std_h265(&pps).unwrap_err(), + H265ParamsError::PalettePredictorInitializers + ); + } + + #[test] + fn oversized_or_corrupt_rps_tables_are_rejected_not_padded() { + // More candidates than the spec's 64. + let mut sps = full_sps(); + sps.num_short_term_ref_pic_sets = 65; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::TooManyShortTermRpsSets(65) + ); + + // A declared count the parser never resolved. + let mut sps = full_sps(); + sps.num_short_term_ref_pic_sets = 1; + sps.short_term_ref_pic_set = Vec::new(); + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::MissingShortTermRps { index: 0 } + ); + + // A set with more entries on one side than the Std arrays hold. + let mut sps = full_sps(); + sps.num_short_term_ref_pic_sets = 1; + sps.short_term_ref_pic_set = vec![ShortTermRefPicSet { + num_negative_pics: 17, + ..Default::default() + }]; + assert!(matches!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::RpsEntryOverflow { set: 0, .. } + )); + + // A non-monotonic DeltaPoc array cannot re-encode as minus1 syntax. + let mut sps = full_sps(); + sps.num_short_term_ref_pic_sets = 1; + let mut set = ShortTermRefPicSet { + num_negative_pics: 2, + ..Default::default() + }; + set.delta_poc_s0[0] = -3; + set.delta_poc_s0[1] = -1; // must be strictly decreasing + sps.short_term_ref_pic_set = vec![set]; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::NonMonotonicRps { set: 0 } + ); + + // Too many long-term SPS candidates. + let mut sps = full_sps(); + sps.long_term_ref_pics_present_flag = true; + sps.num_long_term_ref_pics_sps = 33; + assert_eq!( + sps_to_std_h265(&sps).unwrap_err(), + H265ParamsError::TooManyLongTermSpsPics(33) + ); + } + + #[test] + fn every_mapped_pps_field_and_flag_round_trips_exactly() { + let pps = full_pps(full_sps()); + let owned = pps_to_std_h265(&pps).unwrap(); + let std = owned.std(); + + // The fixture's mixed pattern, bit for bit. + assert_eq!(std.flags.dependent_slice_segments_enabled_flag(), 1); + assert_eq!(std.flags.output_flag_present_flag(), 0); + assert_eq!(std.flags.sign_data_hiding_enabled_flag(), 1); + assert_eq!(std.flags.cabac_init_present_flag(), 0); + assert_eq!(std.flags.constrained_intra_pred_flag(), 1); + assert_eq!(std.flags.transform_skip_enabled_flag(), 0); + assert_eq!(std.flags.cu_qp_delta_enabled_flag(), 1); + assert_eq!(std.flags.pps_slice_chroma_qp_offsets_present_flag(), 0); + assert_eq!(std.flags.weighted_pred_flag(), 1); + assert_eq!(std.flags.weighted_bipred_flag(), 0); + assert_eq!(std.flags.transquant_bypass_enabled_flag(), 1); + assert_eq!(std.flags.tiles_enabled_flag(), 1); + assert_eq!(std.flags.entropy_coding_sync_enabled_flag(), 0); + assert_eq!(std.flags.uniform_spacing_flag(), 0); + assert_eq!(std.flags.loop_filter_across_tiles_enabled_flag(), 1); + assert_eq!(std.flags.pps_loop_filter_across_slices_enabled_flag(), 0); + assert_eq!(std.flags.deblocking_filter_control_present_flag(), 1); + assert_eq!(std.flags.deblocking_filter_override_enabled_flag(), 0); + assert_eq!(std.flags.pps_deblocking_filter_disabled_flag(), 1); + assert_eq!(std.flags.pps_scaling_list_data_present_flag(), 0); + assert_eq!(std.flags.lists_modification_present_flag(), 1); + assert_eq!(std.flags.slice_segment_header_extension_present_flag(), 0); + assert_eq!(std.flags.pps_extension_present_flag(), 1); + assert_eq!(std.flags.cross_component_prediction_enabled_flag(), 1); + assert_eq!(std.flags.chroma_qp_offset_list_enabled_flag(), 1); + assert_eq!(std.flags.pps_curr_pic_ref_enabled_flag(), 0); + assert_eq!( + std.flags.residual_adaptive_colour_transform_enabled_flag(), + 0 + ); + assert_eq!(std.flags.pps_slice_act_qp_offsets_present_flag(), 0); + assert_eq!( + std.flags.pps_palette_predictor_initializers_present_flag(), + 0 + ); + assert_eq!(std.flags.monochrome_palette_flag(), 0); + assert_eq!(std.flags.pps_range_extension_flag(), 1); + + assert_eq!(std.pps_pic_parameter_set_id, 3); + assert_eq!(std.pps_seq_parameter_set_id, 5); + assert_eq!( + std.sps_video_parameter_set_id, 2, + "resolved through the PPS's own SPS" + ); + assert_eq!(std.num_extra_slice_header_bits, 2); + assert_eq!(std.num_ref_idx_l0_default_active_minus1, 2); + assert_eq!(std.num_ref_idx_l1_default_active_minus1, 1); + assert_eq!(std.init_qp_minus26, -3); + assert_eq!(std.diff_cu_qp_delta_depth, 2); + assert_eq!(std.pps_cb_qp_offset, -4); + assert_eq!(std.pps_cr_qp_offset, 5); + assert_eq!(std.pps_beta_offset_div2, -2); + assert_eq!(std.pps_tc_offset_div2, 3); + assert_eq!(std.log2_parallel_merge_level_minus2, 1); + assert_eq!(std.log2_max_transform_skip_block_size_minus2, 2); + assert_eq!(std.diff_cu_chroma_qp_offset_depth, 1); + assert_eq!(std.chroma_qp_offset_list_len_minus1, 1); + assert_eq!(&std.cb_qp_offset_list[..2], &[1, -2]); + assert_eq!(&std.cr_qp_offset_list[..2], &[-3, 4]); + assert_eq!(std.log2_sao_offset_scale_luma, 1); + assert_eq!(std.log2_sao_offset_scale_chroma, 2); + assert_eq!(std.num_tile_columns_minus1, 1); + assert_eq!(std.num_tile_rows_minus1, 2); + assert_eq!(&std.column_width_minus1[..2], &[17, 12]); + assert_eq!(&std.row_height_minus1[..3], &[9, 8, 16]); + assert!(std.pScalingLists.is_null()); + assert!(std.pPredictorPaletteEntries.is_null()); + } + + #[test] + fn a_pps_field_past_its_std_width_is_an_error_not_a_truncation() { + let mut pps = full_pps(full_sps()); + pps.column_width_minus1[0] = 70_000; // past u16 + assert!(matches!( + pps_to_std_h265(&pps).unwrap_err(), + H265ParamsError::FieldOverflow { + field: "column_width_minus1", + value: 70_000 + } + )); + + let mut pps = full_pps(full_sps()); + pps.range_extension.log2_sao_offset_scale_luma = 300; // past u8 + assert!(matches!( + pps_to_std_h265(&pps).unwrap_err(), + H265ParamsError::FieldOverflow { .. } + )); + } + + #[test] + fn pps_scaling_lists_ride_behind_the_owned_pointer() { + let mut pps = full_pps(full_sps()); + pps.scaling_list_data_present_flag = true; + pps.scaling_list.scaling_list_4x4 = std::array::from_fn(|i| [60 + i as u8; 16]); + let owned = Box::new(pps_to_std_h265(&pps).unwrap()); + assert_eq!(owned.std().flags.pps_scaling_list_data_present_flag(), 1); + // SAFETY: pScalingLists targets `owned`'s boxed backing, alive here. + let lists = unsafe { &*owned.std().pScalingLists }; + assert_eq!(lists.ScalingList4x4[5], [65; 16]); + } + + #[test] + fn a_vps_converts_with_hrd_and_timing_skipped_by_design() { + let vps = Vps { + video_parameter_set_id: 2, + max_sub_layers_minus1: 1, + temporal_id_nesting_flag: true, + sub_layer_ordering_info_present_flag: true, + profile_tier_level: full_sps().profile_tier_level, + max_dec_pic_buffering_minus1: [5, 6, 0, 0, 0, 0, 0], + max_num_reorder_pics: [1, 2, 0, 0, 0, 0, 0], + max_latency_increase_plus1: [7, 8, 0, 0, 0, 0, 0], + // Timing present at the SOURCE: the skip must not copy it. + timing_info_present_flag: true, + num_units_in_tick: 1000, + time_scale: 60_000, + ..Default::default() + }; + let owned = vps_to_std_h265(&vps).unwrap(); + let std = owned.std(); + assert_eq!(std.vps_video_parameter_set_id, 2); + assert_eq!(std.vps_max_sub_layers_minus1, 1); + assert_eq!(std.flags.vps_temporal_id_nesting_flag(), 1); + assert_eq!(std.flags.vps_sub_layer_ordering_info_present_flag(), 1); + assert_eq!( + std.flags.vps_timing_info_present_flag(), + 0, + "true at the source, skipped by design (HRD/timing)" + ); + assert_eq!(std.vps_num_units_in_tick, 0); + assert_eq!(std.vps_time_scale, 0); + assert!(std.pHrdParameters.is_null()); + // SAFETY: both pointers target `owned`'s boxed backings, alive here. + let (ptl, dpb) = unsafe { (&*std.pProfileTierLevel, &*std.pDecPicBufMgr) }; + assert_eq!( + ptl.general_profile_idc, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN_10 + ); + assert_eq!(&dpb.max_dec_pic_buffering_minus1[..2], &[5, 6]); + assert_eq!(&dpb.max_latency_increase_plus1[..2], &[7, 8]); + + // A VPS whose DPB sizing overflows the Std u8 fields is corrupt. + let mut hostile = vps; + hostile.max_dec_pic_buffering_minus1[0] = 300; + assert!(matches!( + vps_to_std_h265(&hostile).unwrap_err(), + H265ParamsError::FieldOverflow { .. } + )); + } + + #[test] + fn the_fallback_vps_restates_exactly_what_the_sps_knows() { + let sps = full_sps(); + let owned = fallback_vps_from_sps(&sps).unwrap(); + let std = owned.std(); + assert_eq!(std.vps_video_parameter_set_id, sps.video_parameter_set_id); + assert_eq!(std.vps_max_sub_layers_minus1, sps.max_sub_layers_minus1); + // SAFETY: both pointers target `owned`'s boxed backings, alive here. + let (ptl, dpb) = unsafe { (&*std.pProfileTierLevel, &*std.pDecPicBufMgr) }; + assert_eq!( + ptl.general_level_idc, + hh::StdVideoH265LevelIdc_STD_VIDEO_H265_LEVEL_IDC_4_1 + ); + assert_eq!( + dpb.max_dec_pic_buffering_minus1, + sps.max_dec_pic_buffering_minus1 + ); + assert_eq!(dpb.max_num_reorder_pics, sps.max_num_reorder_pics); + } + + #[test] + fn the_25fps_vectors_own_parameter_sets_convert_cleanly() { + use std::io::Cursor; + + use cros_codecs::codec::h265::parser::Nalu; + use cros_codecs::codec::h265::parser::NaluType; + use cros_codecs::codec::h265::parser::Parser; + + // The same vendored vector pf-bitstream's h265 tests plan, same path. + const TEST_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" + ); + + let mut cursor = Cursor::new(TEST_25FPS); + let mut parser = Parser::default(); + let (mut vps_seen, mut sps_seen, mut pps_seen) = (false, false, false); + while let Ok(nalu) = Nalu::next(&mut cursor) { + match nalu.header.type_ { + NaluType::VpsNut if !vps_seen => { + let vps = parser.parse_vps(&nalu).expect("the vector's VPS parses"); + vps_to_std_h265(vps).expect("the vector's VPS converts"); + vps_seen = true; + } + NaluType::SpsNut if !sps_seen => { + let sps = parser.parse_sps(&nalu).expect("the vector's SPS parses"); + let owned = sps_to_std_h265(sps).expect("the vector's SPS converts"); + let std = owned.std(); + // The vector's own goldens: 320x240 8-bit 4:2:0 Main. + assert_eq!(std.pic_width_in_luma_samples, 320, "the vector is 320x240"); + assert_eq!(std.pic_height_in_luma_samples, 240); + assert_eq!(std.bit_depth_luma_minus8, 0); + assert_eq!( + std.chroma_format_idc, + hh::StdVideoH265ChromaFormatIdc_STD_VIDEO_H265_CHROMA_FORMAT_IDC_420 + ); + // SAFETY: pProfileTierLevel targets `owned`'s boxed backing. + let ptl = unsafe { &*std.pProfileTierLevel }; + assert_eq!( + ptl.general_profile_idc, + hh::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN + ); + sps_seen = true; + } + NaluType::PpsNut if !pps_seen => { + let pps = parser.parse_pps(&nalu).expect("the vector's PPS parses"); + let owned = pps_to_std_h265(pps).expect("the vector's PPS converts"); + assert_eq!(owned.std().pps_pic_parameter_set_id, 0); + pps_seen = true; + } + _ => {} + } + if vps_seen && sps_seen && pps_seen { + break; + } + } + assert!( + vps_seen && sps_seen && pps_seen, + "the vector opens with VPS + SPS + PPS" + ); + } +} diff --git a/crates/pf-vkdecode/src/pic.rs b/crates/pf-vkdecode/src/pic.rs index 3c6e7b85..2e7c0eda 100644 --- a/crates/pf-vkdecode/src/pic.rs +++ b/crates/pf-vkdecode/src/pic.rs @@ -31,9 +31,13 @@ pub struct VkRef { #[derive(Debug, Clone)] pub struct DecodePlanVk { pub std_pic: hh::StdVideoDecodeH264PictureInfo, - /// Byte offset of each slice NALU in the submitted AU, START CODE INCLUDED — - /// Vulkan's `pSliceOffsets` points at start codes within the bitstream buffer, - /// and punktfunk submits the AU exactly as planned. + /// Byte offset of each slice NALU in the AU as planned, START CODE INCLUDED. + /// AU-relative, NOT submission-final: the recording layer packs the SLICE + /// NALUs alone into the bitstream buffer and rebases these offsets while + /// doing so (non-VCL NALUs inside the decode range hang VCN firmware — see + /// the slices-only packing in `decoder.rs`); Vulkan's `pSliceOffsets` + /// receives the rebased offsets, each pointing at a start code within the + /// packed buffer. pub slice_offsets: Vec, /// The slot the decoded picture activates (`pSetupReferenceSlot`). pub setup_slot: u8, diff --git a/crates/pf-vkdecode/src/pic_h265.rs b/crates/pf-vkdecode/src/pic_h265.rs new file mode 100644 index 00000000..3c4e08da --- /dev/null +++ b/crates/pf-vkdecode/src/pic_h265.rs @@ -0,0 +1,1116 @@ +//! Per-AU H.265 conversion: one [`AuPlan`] into the `StdVideoDecodeH265*` structs, +//! slice offsets and DPB slot bindings a `vkCmdDecodeVideoKHR` call is built from — +//! [`crate::pic`] one codec over (M3's CPU half; the session/recording half is a +//! later WP). +//! +//! The codec difference that shapes this module: Vulkan H.265 decode takes NO +//! per-slice reference lists. The hardware re-derives 8.3.4's lists itself from +//! the slice bits, keyed by the picture-level RPS index arrays +//! (`RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr`) — so the AU-level binding set +//! here is the union of the plan's three CURRENT reference picture sets +//! ([`pf_bitstream::h265::RpsPlan`]), not the union of slice lists as in H.264. +//! The plan's per-slice lists still exist and are used as a cross-check: every +//! list entry must be a member of the binding set, or the conversion fails closed. +//! +//! Concealment note: a lost reference is ABSENT from the plan's RPS sets (flagged +//! upstream via `PlanWarning::MissingReference`), so the Std index arrays compact +//! past it — later positions shift by one relative to the damaged stream's +//! intent. That is deliberate: there is no slot to point at, `0xFF` padding keeps +//! the arrays well-formed, and the session layer has already been told to request +//! recovery. The alternative (fabricating an entry) is exactly what this crate +//! never does. + +use ash::vk::native as hh; +use pf_bitstream::h265::AuPlan; +use pf_bitstream::h265::PicId; +use pf_bitstream::h265::RefPic; +use tracing::trace; + +use crate::slots::SlotError; +use crate::slots::SlotMap; + +/// `STD_VIDEO_DECODE_H265_REF_PIC_SET_LIST_SIZE`: each Std RPS index array holds +/// eight entries — the hard ceiling on how many CURRENT references one set may +/// carry through Vulkan (the spec itself allows up to 16 per side; beyond eight +/// is unexpressible and rejected, see [`PlanToVkH265Error::RpsSetOverflow`]). +pub const H265_RPS_LIST_SIZE: usize = 8; + +/// The Std sentinel for an unused RPS index-array entry. +const UNUSED_RPS_ENTRY: u8 = 0xFF; + +/// One active reference of the AU: its DPB slot, its Std reference info, and the +/// planner id it resolves (kept so the backend can map the slot to its image). +#[derive(Debug, Clone)] +pub struct VkRefH265 { + pub slot: u8, + pub std: hh::StdVideoDecodeH265ReferenceInfo, + pub id: PicId, +} + +/// Everything CPU-derivable of one AU's decode submission. The GPU half adds the +/// live objects: bitstream buffer, DPB images, session and command recording. +#[derive(Debug, Clone)] +pub struct DecodePlanVkH265 { + /// The picture info. Its `RefPicSetStCurrBefore`/`StCurrAfter`/`LtCurr` + /// arrays hold INDICES INTO [`Self::refs`] (`0xFF` = unused) — the backend + /// MUST lay out `pReferenceSlots` in exactly [`Self::refs`] order, because + /// Vulkan defines these arrays as indices into that array. + pub std_pic: hh::StdVideoDecodeH265PictureInfo, + /// Byte offset of each slice segment NALU in the AU as planned, START CODE + /// INCLUDED. AU-relative, NOT submission-final: the recording layer must + /// pack the SLICE NALUs alone into the bitstream buffer and rebase these + /// offsets while doing so — non-VCL NALUs inside the decode range hang VCN + /// firmware (the H.264 decoder's slices-only packing exists for exactly + /// that `vcn_unified_0` ring timeout; FFmpeg feeds slices-only for the same + /// reason). Vulkan's `pSliceSegmentOffsets` then receives the REBASED + /// offsets, each pointing at a start code within the packed buffer. + pub slice_offsets: Vec, + /// The slot the decoded picture activates (`pSetupReferenceSlot`). + pub setup_slot: u8, + /// Reference info for the setup slot: the picture's own POC, short-term. + /// HEVC has no same-AU self-marking (H.264's IDR long_term_reference_flag / + /// MMCO 6): C.3.4 marks every stored picture "used for short-term reference", + /// and a picture turns long-term only when a LATER picture's RPS lists it in + /// `RefPicSetLtCurr` — at which point that AU's [`Self::refs`] entry carries + /// the long-term flag. + pub setup_ref: hh::StdVideoDecodeH265ReferenceInfo, + /// The planner id of the decoded picture (`AuPlan.dpb.stored`) — the backend + /// keys its image bookkeeping by it. + pub setup_id: PicId, + /// Whether the decoded picture may be referenced by later pictures (false + /// for sub-layer non-reference NALU types, RASL_N/TRAIL_N and friends). When + /// `false` the setup slot exists for the decode itself plus any remaining + /// DPB residency, and must never be bound as a reference for later AUs. + pub setup_is_reference: bool, + /// The unique referenced pictures of this AU — the union of the plan's three + /// current RPS sets in set order (StCurrBefore, StCurrAfter, LtCurr), first + /// appearance first. [`Self::std_pic`]'s index arrays point into this Vec. + pub refs: Vec, +} + +/// Conversion failures. Stream damage never lands here — pf-bitstream degrades +/// it to [`pf_bitstream::h265::PlanWarning`]s upstream; these are caller/session +/// bugs or envelope limits. (`PlanError::RaslSkipped` also never reaches this +/// layer: it is an error OF planning, handled as an Ok-skip by the client +/// wiring, and no plan exists to convert.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlanToVkH265Error { + /// The plan holds no slices; there is nothing to submit. + NoSlices, + /// The plan's `DpbUpdate.stored` is `None`. `plan_au` always stores; only + /// `flush()` produces such updates, and those go to [`SlotMap::apply`] + /// directly. + NoStoredId, + /// An RPS entry's id holds no slot: an earlier plan of this stream never + /// went through this [`SlotMap`]. + UnresolvedReference(PicId), + /// A slice reference-list entry names a picture outside the plan's current + /// RPS sets. 8.3.4 builds every list FROM those sets, so this is a planner + /// contract violation — the picture would be missing from + /// `pReferenceSlots` and the hardware could not resolve it. + ReferenceOutsideRps(PicId), + Slot(SlotError), + /// A slice offset exceeds `u32` (Vulkan submits offsets as `u32`). + OffsetOverflow(usize), + /// A current RPS set holds more entries than the Std index arrays' eight + /// ([`H265_RPS_LIST_SIZE`]) — expressible in H.265, not in Vulkan; outside + /// the program envelope (punktfunk hosts keep well under it). + RpsSetOverflow { + set: &'static str, + len: usize, + }, + /// The first slice's inline `st_ref_pic_set()` predicts from an SPS + /// candidate that does not exist — `NumDeltaPocsOfRefRpsIdx` cannot be + /// derived, and the hardware would misparse the slice header. + InvalidRefRpsIdx { + curr_rps_idx: u8, + delta_idx_minus1: u8, + }, + /// The inline `st_ref_pic_set()`'s bit count exceeds `u16` (the Std field + /// `NumBitsForSTRefPicSetInSlice`) — a header that large is corrupt. + StRpsBitsOverflow(u32), + /// The predicted-from candidate's `NumDeltaPocs` exceeds `u8` (the Std + /// field `NumDeltaPocsOfRefRpsIdx`). Impossible off a real parse (≤ 32); + /// a directly-constructed plan gets an error, never a clamped count the + /// hardware would misparse the slice header with. + NumDeltaPocsOverflow(u32), + /// The map was built for a different DPB depth than this plan's + /// `max_dpb_frames` — an SPS renegotiation resized the DPB. The session + /// must rebuild the video session and its [`SlotMap`]; converting against + /// the stale map would hand out slot indices the session's image pool does + /// not have (the H.264 module's exact contract). + CapacityMismatch { + required: usize, + capacity: usize, + }, +} + +impl std::fmt::Display for PlanToVkH265Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PlanToVkH265Error::NoSlices => write!(f, "the plan holds no slices"), + PlanToVkH265Error::NoStoredId => { + write!( + f, + "the plan stores no picture (flush updates go to SlotMap::apply)" + ) + } + PlanToVkH265Error::UnresolvedReference(id) => { + write!(f, "referenced picture {id} holds no DPB slot in this map") + } + PlanToVkH265Error::ReferenceOutsideRps(id) => { + write!( + f, + "slice list references picture {id} outside the current RPS sets" + ) + } + PlanToVkH265Error::Slot(err) => write!(f, "slot assignment failed: {err}"), + PlanToVkH265Error::OffsetOverflow(offset) => { + write!(f, "slice offset {offset} exceeds u32") + } + PlanToVkH265Error::RpsSetOverflow { set, len } => { + write!( + f, + "{set} holds {len} entries; Vulkan expresses at most {H265_RPS_LIST_SIZE}" + ) + } + PlanToVkH265Error::InvalidRefRpsIdx { + curr_rps_idx, + delta_idx_minus1, + } => { + write!( + f, + "inline st_ref_pic_set predicts from a nonexistent candidate \ + (CurrRpsIdx {curr_rps_idx}, delta_idx_minus1 {delta_idx_minus1})" + ) + } + PlanToVkH265Error::StRpsBitsOverflow(bits) => { + write!(f, "st_ref_pic_set bit count {bits} exceeds u16") + } + PlanToVkH265Error::NumDeltaPocsOverflow(count) => { + write!(f, "candidate NumDeltaPocs {count} exceeds u8") + } + PlanToVkH265Error::CapacityMismatch { required, capacity } => { + write!( + f, + "the plan needs {required} slots but the map holds {capacity} — \ + an SPS renegotiation resized the DPB; rebuild session and map" + ) + } + } + } +} + +impl std::error::Error for PlanToVkH265Error { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + PlanToVkH265Error::Slot(err) => Some(err), + _ => None, + } + } +} + +impl From for PlanToVkH265Error { + fn from(err: SlotError) -> Self { + PlanToVkH265Error::Slot(err) + } +} + +/// One [`RefPic`] as Std reference info. +fn ref_info(rp: &RefPic) -> hh::StdVideoDecodeH265ReferenceInfo { + // SAFETY: StdVideoDecodeH265ReferenceInfo is a plain-C bindgen struct of a + // bitfield word and one integer; all-zero is a valid value for every field. + let mut std: hh::StdVideoDecodeH265ReferenceInfo = unsafe { std::mem::zeroed() }; + std.flags + .set_used_for_long_term_reference(u32::from(rp.is_long_term)); + // unused_for_reference stays 0: membership in a CURRENT set is the + // definition of being used for reference by this picture. + std.PicOrderCntVal = rp.pic_order_cnt; + std +} + +/// `NumDeltaPocsOfRefRpsIdx` (the Std picture-info field): when the first +/// slice's inline `st_ref_pic_set()` uses inter-RPS prediction, the hardware +/// re-parses those slice bits and needs `NumDeltaPocs[RefRpsIdx]` of the SOURCE +/// candidate to size the `used_by_curr_pic_flag`/`use_delta_flag` loop (7.4.8); +/// otherwise 0. +fn num_delta_pocs_of_ref_rps_idx(plan: &AuPlan) -> Result { + let hdr = &plan + .slices + .first() + .expect("caller validated the plan holds slices") + .header; + // Inline means CurrRpsIdx == num_short_term_ref_pic_sets (8.3.2 NOTE 2); + // an SPS-indexed RPS re-parses nothing in the slice header. + let inline = !hdr.short_term_ref_pic_set_sps_flag + && hdr.curr_rps_idx == plan.sps.num_short_term_ref_pic_sets; + if !inline || !hdr.short_term_ref_pic_set.inter_ref_pic_set_prediction_flag { + return Ok(0); + } + // RefRpsIdx = stRpsIdx - (delta_idx_minus1 + 1), stRpsIdx = CurrRpsIdx here + // (equation 7-59). u16 arithmetic so a hostile delta cannot wrap. + let delta = hdr.short_term_ref_pic_set.delta_idx_minus1; + let source = u16::from(hdr.curr_rps_idx) + .checked_sub(u16::from(delta) + 1) + .and_then(|idx| plan.sps.short_term_ref_pic_set.get(usize::from(idx))) + .ok_or(PlanToVkH265Error::InvalidRefRpsIdx { + curr_rps_idx: hdr.curr_rps_idx, + delta_idx_minus1: delta, + })?; + // NumDeltaPocs = num_negative + num_positive <= 32 off any real parse, + // comfortably u8 — but a directly-constructed plan could exceed it, and a + // silently clamped count would misparse the slice header on hardware: + // typed error, like everything else in this file. + u8::try_from(source.num_delta_pocs) + .map_err(|_| PlanToVkH265Error::NumDeltaPocsOverflow(source.num_delta_pocs)) +} + +/// Convert one planned AU, driving `slots` through the AU's slot lifecycle. +/// +/// Unlike the H.264 [`crate::plan_to_vk`], no `sps_id` parameter: an H.265 +/// [`AuPlan`] carries its activated SPS and PPS, so every id resolves from the +/// plan itself. +/// +/// Atomicity contract (identical to the H.264 module): every fallible step runs +/// before any mutation of `slots`, so an error leaves the map exactly as it was. +/// In order: +/// 1. capacity is validated against the plan's `max_dpb_frames` (read-only); +/// 2. the RPS binding set resolves against the PRE-removal state (read-only) — +/// a current-set member always survives its own AU (8.3.2's marking keeps it +/// referenced), but resolving before removals keeps the transaction shape +/// byte-for-byte the H.264 one and costs nothing; +/// 3. slice lists are cross-checked and offsets validated (read-only); +/// 4. `removed` is applied — removals were real regardless of this AU's fate — +/// and the setup slot is assigned last. A stored-and-evicted picture (its id +/// in this same plan's `removed`) still gets its slot for the decode itself +/// and is released right after, exactly the H.264 defensive path. +pub fn plan_to_vk_h265( + plan: &AuPlan, + slots: &mut SlotMap, +) -> Result { + let first_slice = plan.slices.first().ok_or(PlanToVkH265Error::NoSlices)?; + let setup_id = plan.dpb.stored.ok_or(PlanToVkH265Error::NoStoredId)?; + + // The map must match THIS plan's DPB depth; a mismatch means an SPS + // renegotiation resized the DPB and the session must be rebuilt. + let required = plan.picture.max_dpb_frames + 1; + if slots.capacity() != required { + return Err(PlanToVkH265Error::CapacityMismatch { + required, + capacity: slots.capacity(), + }); + } + + // The AU-level binding set: the union of the three current RPS sets, first + // appearance first (module docs — Vulkan H.265 keys everything by these, + // not by slice lists). Each picture appears once even if a corrupt stream's + // concealment resolved two entries to the same stored picture. + let mut refs: Vec = Vec::new(); + let mut index_arrays = [[UNUSED_RPS_ENTRY; H265_RPS_LIST_SIZE]; 3]; + let sets: [(&'static str, &[RefPic]); 3] = [ + ("RefPicSetStCurrBefore", &plan.rps.st_curr_before), + ("RefPicSetStCurrAfter", &plan.rps.st_curr_after), + ("RefPicSetLtCurr", &plan.rps.lt_curr), + ]; + for (array, (name, set)) in index_arrays.iter_mut().zip(sets) { + if set.len() > H265_RPS_LIST_SIZE { + return Err(PlanToVkH265Error::RpsSetOverflow { + set: name, + len: set.len(), + }); + } + for (position, rp) in set.iter().enumerate() { + let index = match refs.iter().position(|existing| existing.id == rp.id) { + Some(index) => { + // A concealment-resolved duplicate across sets (doc above): + // the stored picture binds ONCE, but if ANY occurrence + // marks it long-term the binding must say so — hardware + // treats LT references differently (no MV scaling, POC-LSB + // matching), and an `RefPicSetLtCurr` index into a + // short-term-marked slot is an internally inconsistent DPB. + if rp.is_long_term { + refs[index].std.flags.set_used_for_long_term_reference(1); + } + index + } + None => { + let slot = slots + .slot_of(rp.id) + .ok_or(PlanToVkH265Error::UnresolvedReference(rp.id))?; + refs.push(VkRefH265 { + slot, + std: ref_info(rp), + id: rp.id, + }); + refs.len() - 1 + } + }; + // refs holds at most 3 x 8 entries, far inside u8 (and below the + // 0xFF sentinel). + array[position] = index as u8; + } + } + + // Cross-check: 8.3.4 builds every slice list from the current sets, so any + // entry outside the binding set is a planner-contract violation the + // hardware could not resolve (error-type docs). + for slice in &plan.slices { + for rp in slice.ref_list0.iter().chain(&slice.ref_list1) { + if !refs.iter().any(|existing| existing.id == rp.id) { + return Err(PlanToVkH265Error::ReferenceOutsideRps(rp.id)); + } + } + } + + let pic = &plan.picture; + + // SAFETY: StdVideoDecodeH265PictureInfo is a plain-C bindgen struct of a + // bitfield word, integers and byte arrays; all-zero is a valid value for + // every field. + let mut std_pic: hh::StdVideoDecodeH265PictureInfo = unsafe { std::mem::zeroed() }; + std_pic.flags.set_IrapPicFlag(u32::from(pic.is_irap)); + std_pic.flags.set_IdrPicFlag(u32::from(pic.is_idr)); + std_pic.flags.set_IsReference(u32::from(pic.is_reference)); + std_pic.flags.set_short_term_ref_pic_set_sps_flag(u32::from( + first_slice.header.short_term_ref_pic_set_sps_flag, + )); + std_pic.sps_video_parameter_set_id = plan.sps.video_parameter_set_id; + std_pic.pps_seq_parameter_set_id = plan.pps.seq_parameter_set_id; + std_pic.pps_pic_parameter_set_id = plan.pps.pic_parameter_set_id; + std_pic.NumDeltaPocsOfRefRpsIdx = num_delta_pocs_of_ref_rps_idx(plan)?; + std_pic.PicOrderCntVal = pic.pic_order_cnt; + // 0 when the RPS came from the SPS by index (PicturePlan field docs) — + // exactly Vulkan's convention for this field. + std_pic.NumBitsForSTRefPicSetInSlice = u16::try_from(pic.short_term_ref_pic_set_size_bits) + .map_err(|_| PlanToVkH265Error::StRpsBitsOverflow(pic.short_term_ref_pic_set_size_bits))?; + [ + std_pic.RefPicSetStCurrBefore, + std_pic.RefPicSetStCurrAfter, + std_pic.RefPicSetLtCurr, + ] = index_arrays; + + // The setup slot's reference info: the picture's own identity, short-term + // (see the DecodePlanVkH265 field docs for why there is no long-term leg + // here, unlike H.264). + // SAFETY: as above — all-zero is a valid StdVideoDecodeH265ReferenceInfo. + let mut setup_ref: hh::StdVideoDecodeH265ReferenceInfo = unsafe { std::mem::zeroed() }; + setup_ref.PicOrderCntVal = pic.pic_order_cnt; + + let mut slice_offsets = Vec::with_capacity(plan.slices.len()); + for slice in &plan.slices { + // SlicePlan.data starts at the slice NALU's start code — exactly the + // offset Vulkan wants (struct docs). + slice_offsets.push( + u32::try_from(slice.data.start) + .map_err(|_| PlanToVkH265Error::OffsetOverflow(slice.data.start))?, + ); + } + + // Mutations LAST, after every fallible step above (fn docs). Removals first + // — they were real regardless of this AU's fate — then the setup + // assignment, releasing immediately when this very plan already evicted the + // stored picture (the H.264 defensive path; the slot must still exist for + // the decode itself). + let setup_evicted = plan.dpb.removed.contains(&setup_id); + for &id in &plan.dpb.removed { + if id == setup_id { + continue; + } + if !slots.release(id) { + // Tolerated but never silent: reachable only when the caller + // skipped feeding an AU's plan through this map. + trace!(id, "DpbUpdate removed an id this SlotMap never assigned"); + } + } + let setup_slot = slots.assign(setup_id)?; + if setup_evicted { + slots.release(setup_id); + } + + Ok(DecodePlanVkH265 { + std_pic, + slice_offsets, + setup_slot, + setup_ref, + setup_id, + setup_is_reference: pic.is_reference, + refs, + }) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::io::Cursor; + use std::rc::Rc; + + use cros_codecs::codec::h265::parser::Nalu; + use cros_codecs::codec::h265::parser::Pps; + use cros_codecs::codec::h265::parser::ShortTermRefPicSet; + use cros_codecs::codec::h265::parser::Sps; + use pf_bitstream::h265::ColourDescription; + use pf_bitstream::h265::DisplayCrop; + use pf_bitstream::h265::DpbUpdate; + use pf_bitstream::h265::H265Planner; + use pf_bitstream::h265::Level; + use pf_bitstream::h265::NaluType; + use pf_bitstream::h265::PicturePlan; + use pf_bitstream::h265::RpsPlan; + use pf_bitstream::h265::SliceHeader; + use pf_bitstream::h265::SlicePlan; + + use super::*; + + // The same vendored vectors pf-bitstream's h265 tests plan (its goldens: + // 250 AUs / 250 slices for the 25fps clip), included from the same path. + const TEST_25FPS: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265" + ); + const TEST_64X64_I_P_B_P: &[u8] = include_bytes!( + "../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/64x64-I-P-B-P.h265" + ); + + /// Test-only AU splitter, mirroring pf-bitstream's h265 helper (which is + /// `#[cfg(test)]`-private there): a new AU starts at a non-VCL NALU + /// following slices, or at a slice segment with + /// `first_slice_segment_in_pic_flag == 1` (the first bit of the byte after + /// the 2-byte NAL header) when the current AU already has slices. + fn split_into_aus(stream: &[u8]) -> Vec<&[u8]> { + let mut aus = Vec::new(); + let mut cursor = Cursor::new(stream); + let mut au_start = 0usize; + let mut au_has_slice = false; + + while let Ok(nalu) = Nalu::next(&mut cursor) { + let header_start = cursor.position() as usize; + let start = header_start - nalu.offset; + let is_slice = (nalu.header.type_ as u32) < 32; + let first_slice_flag = + is_slice && stream.get(header_start + 2).is_some_and(|b| b & 0x80 != 0); + + if au_has_slice && (!is_slice || first_slice_flag) { + aus.push(&stream[au_start..start]); + au_start = start; + au_has_slice = false; + } + au_has_slice |= is_slice; + } + aus.push(&stream[au_start..]); + aus + } + + #[test] + fn the_full_25fps_vector_converts_with_stable_slots_and_start_code_offsets() { + let aus = split_into_aus(TEST_25FPS); + let mut planner = H265Planner::new(); + let mut slots: Option = None; + // PicId -> the slot it was assigned; entries leave only on `removed`. + let mut held: BTreeMap = BTreeMap::new(); + let mut converted = 0usize; + + for au in &aus { + let plan = planner.plan_au(au).expect("the clean vector plans"); + let slots = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + let vk = plan_to_vk_h265(&plan, slots).expect("the clean vector converts"); + converted += 1; + + // Slot stability: every reference resolves to the slot its picture + // was assigned when IT was decoded, and no ref shares the setup slot. + for r in &vk.refs { + assert_eq!( + held.get(&r.id), + Some(&r.slot), + "a referenced picture's slot changed while it was referenced" + ); + assert_ne!(r.slot, vk.setup_slot, "a reference aliases the setup slot"); + } + + // The Std index arrays resolve exactly the plan's RPS sets, in + // order, 0xFF beyond. + for (array, set) in [ + (&vk.std_pic.RefPicSetStCurrBefore, &plan.rps.st_curr_before), + (&vk.std_pic.RefPicSetStCurrAfter, &plan.rps.st_curr_after), + (&vk.std_pic.RefPicSetLtCurr, &plan.rps.lt_curr), + ] { + for (position, entry) in array.iter().enumerate() { + match set.get(position) { + Some(rp) => { + let r = &vk.refs[usize::from(*entry)]; + assert_eq!(r.id, rp.id); + assert_eq!(r.std.PicOrderCntVal, rp.pic_order_cnt); + } + None => assert_eq!(*entry, UNUSED_RPS_ENTRY), + } + } + } + + // Slice offsets: one per slice, each at a start-code boundary of + // the AU, exactly where the plan said the slice begins. + assert_eq!(vk.slice_offsets.len(), plan.slices.len()); + for (offset, slice) in vk.slice_offsets.iter().zip(&plan.slices) { + let offset = *offset as usize; + assert_eq!(offset, slice.data.start); + let at = &au[offset..]; + assert!( + at.starts_with(&[0, 0, 1]) || at.starts_with(&[0, 0, 0, 1]), + "slice offset {offset} does not sit on a start code" + ); + } + + assert_eq!(vk.std_pic.PicOrderCntVal, plan.picture.pic_order_cnt); + assert_eq!( + u32::from(plan.picture.is_idr), + vk.std_pic.flags.IdrPicFlag() + ); + assert_eq!( + u32::from(plan.picture.is_irap), + vk.std_pic.flags.IrapPicFlag() + ); + assert_eq!( + u32::from(plan.picture.is_reference), + vk.std_pic.flags.IsReference() + ); + assert_eq!(vk.setup_ref.PicOrderCntVal, plan.picture.pic_order_cnt); + assert_eq!( + vk.std_pic.NumBitsForSTRefPicSetInSlice, + plan.picture.short_term_ref_pic_set_size_bits as u16 + ); + + // Mirror the map's bookkeeping: record the new picture, drop the + // removed. + let stored = plan.dpb.stored.unwrap(); + assert_eq!(vk.setup_id, stored); + assert_eq!(vk.setup_is_reference, plan.picture.is_reference); + held.insert(stored, vk.setup_slot); + for id in &plan.dpb.removed { + held.remove(id); + } + + // held() must mirror the plan-driven bookkeeping exactly, every AU. + let ledger: BTreeMap = slots.held().map(|(slot, id)| (id, slot)).collect(); + assert_eq!(ledger, held); + } + + assert_eq!(converted, 250, "the vector's own golden"); + + // Teardown: the flush update releases every remaining slot — the + // codec-neutral DpbUpdate drives the SAME SlotMap H.264 uses. + let mut slots = slots.unwrap(); + slots.apply(&planner.flush()); + assert_eq!(slots.active(), 0); + } + + #[test] + fn the_b_frame_vector_populates_both_current_index_arrays_around_the_picture() { + let aus = split_into_aus(TEST_64X64_I_P_B_P); + let mut planner = H265Planner::new(); + let mut slots: Option = None; + let mut b_pictures_seen = 0usize; + + for au in &aus { + let plan = planner.plan_au(au).expect("the clean vector plans"); + let slots = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames)); + let vk = plan_to_vk_h265(&plan, slots).expect("the clean vector converts"); + + if plan.rps.st_curr_after.is_empty() { + continue; + } + b_pictures_seen += 1; + // 8.3.2: StCurrBefore entries sit below the picture's POC, + // StCurrAfter above — through the index arrays and refs. + let before = usize::from(vk.std_pic.RefPicSetStCurrBefore[0]); + let after = usize::from(vk.std_pic.RefPicSetStCurrAfter[0]); + assert!(vk.refs[before].std.PicOrderCntVal < plan.picture.pic_order_cnt); + assert!(vk.refs[after].std.PicOrderCntVal > plan.picture.pic_order_cnt); + assert_ne!(before, after, "distinct pictures on the two sides"); + } + + assert!(b_pictures_seen > 0, "the vector must contain B pictures"); + } + + // ------- hand-built plan fixtures (the vendored crate has no H.265 + // synthesizer; every AuPlan field is public, so edge cases construct the + // planner's output shape directly — the contract under test is the plan, + // not the bitstream) ------- + + fn mini_sps() -> Rc { + Rc::new(Sps { + video_parameter_set_id: 0, + seq_parameter_set_id: 0, + chroma_format_idc: 1, + pic_width_in_luma_samples: 64, + pic_height_in_luma_samples: 64, + ..Default::default() + }) + } + + fn mini_pps(sps: &Rc) -> Rc { + // The vendored Pps derives no Default; only the fields this module + // reads (the two ids and the SPS chain) carry meaning here. + Rc::new(Pps { + pic_parameter_set_id: 0, + seq_parameter_set_id: 0, + dependent_slice_segments_enabled_flag: false, + output_flag_present_flag: false, + num_extra_slice_header_bits: 0, + sign_data_hiding_enabled_flag: false, + cabac_init_present_flag: false, + num_ref_idx_l0_default_active_minus1: 0, + num_ref_idx_l1_default_active_minus1: 0, + init_qp_minus26: 0, + constrained_intra_pred_flag: false, + transform_skip_enabled_flag: false, + cu_qp_delta_enabled_flag: false, + diff_cu_qp_delta_depth: 0, + cb_qp_offset: 0, + cr_qp_offset: 0, + slice_chroma_qp_offsets_present_flag: false, + weighted_pred_flag: false, + weighted_bipred_flag: false, + transquant_bypass_enabled_flag: false, + tiles_enabled_flag: false, + entropy_coding_sync_enabled_flag: false, + num_tile_columns_minus1: 0, + num_tile_rows_minus1: 0, + uniform_spacing_flag: true, + column_width_minus1: [0; 19], + row_height_minus1: [0; 21], + loop_filter_across_tiles_enabled_flag: true, + loop_filter_across_slices_enabled_flag: false, + deblocking_filter_control_present_flag: false, + deblocking_filter_override_enabled_flag: false, + deblocking_filter_disabled_flag: false, + beta_offset_div2: 0, + tc_offset_div2: 0, + scaling_list_data_present_flag: false, + scaling_list: Default::default(), + lists_modification_present_flag: false, + log2_parallel_merge_level_minus2: 0, + slice_segment_header_extension_present_flag: false, + extension_present_flag: false, + range_extension_flag: false, + range_extension: Default::default(), + scc_extension_flag: false, + scc_extension: Default::default(), + qp_bd_offset_y: 0, + sps: Rc::clone(sps), + }) + } + + fn mini_picture(poc: i32, max_dpb_frames: usize) -> PicturePlan { + PicturePlan { + nalu_type: if poc == 0 { + NaluType::IdrWRadl + } else { + NaluType::TrailR + }, + is_idr: poc == 0, + is_irap: poc == 0, + no_rasl_output_flag: poc == 0, + is_reference: true, + pic_order_cnt: poc, + coded_width: 64, + coded_height: 64, + display_crop: DisplayCrop { + x: 0, + y: 0, + width: 64, + height: 64, + }, + colour: ColourDescription { + colour_primaries: 2, + transfer_characteristics: 2, + matrix_coefficients: 2, + video_full_range: false, + }, + general_profile_idc: 1, + level_idc: Level::L4, + bit_depth_luma_minus8: 0, + bit_depth_chroma_minus8: 0, + chroma_format_idc: 1, + max_dpb_frames, + short_term_ref_pic_set_size_bits: 0, + recovery_point: None, + } + } + + fn mini_slice(refs0: &[RefPic], refs1: &[RefPic]) -> SlicePlan { + SlicePlan { + data: 0..32, + header: SliceHeader::default(), + ref_list0: refs0.to_vec(), + ref_list1: refs1.to_vec(), + } + } + + /// A plan storing `stored` with the given RPS sets and one slice whose + /// list0 is the concatenation the 8-8 temporal order would produce. + fn mini_plan( + stored: PicId, + poc: i32, + rps: RpsPlan, + removed: Vec, + max_dpb_frames: usize, + ) -> AuPlan { + let sps = mini_sps(); + let pps = mini_pps(&sps); + let mut list0: Vec = Vec::new(); + list0.extend(rps.st_curr_before.iter().copied()); + list0.extend(rps.st_curr_after.iter().copied()); + list0.extend(rps.lt_curr.iter().copied()); + AuPlan { + picture: mini_picture(poc, max_dpb_frames), + rps, + slices: vec![mini_slice(&list0, &[])], + dpb: DpbUpdate { + stored: Some(stored), + outputs: vec![stored], + removed, + }, + warnings: Vec::new(), + sps, + pps, + } + } + + fn st_ref(id: PicId, poc: i32) -> RefPic { + RefPic { + id, + pic_order_cnt: poc, + is_long_term: false, + } + } + + fn lt_ref(id: PicId, poc: i32) -> RefPic { + RefPic { + id, + pic_order_cnt: poc, + is_long_term: true, + } + } + + #[test] + fn a_long_term_rps_entry_carries_the_flag_and_its_index_lands_in_lt_curr() { + let mut slots = SlotMap::new(4); + // Two pictures already decoded through this map: the anchor (id 10, + // poc 0, pinned long-term) and the previous picture (id 11, poc 1). + slots.assign(10).unwrap(); + slots.assign(11).unwrap(); + + let plan = mini_plan( + 12, + 2, + RpsPlan { + st_curr_before: vec![st_ref(11, 1)], + st_curr_after: Vec::new(), + lt_curr: vec![lt_ref(10, 0)], + }, + Vec::new(), + 4, + ); + let vk = plan_to_vk_h265(&plan, &mut slots).unwrap(); + + assert_eq!(vk.refs.len(), 2); + let st = &vk.refs[usize::from(vk.std_pic.RefPicSetStCurrBefore[0])]; + assert_eq!(st.id, 11); + assert_eq!(st.std.flags.used_for_long_term_reference(), 0); + let lt = &vk.refs[usize::from(vk.std_pic.RefPicSetLtCurr[0])]; + assert_eq!(lt.id, 10); + assert_eq!(lt.std.flags.used_for_long_term_reference(), 1); + assert_eq!(lt.std.PicOrderCntVal, 0); + assert_eq!(vk.std_pic.RefPicSetStCurrAfter[0], UNUSED_RPS_ENTRY); + // The setup picture itself activates short-term (no same-AU + // self-marking in HEVC — struct docs). + assert_eq!(vk.setup_ref.flags.used_for_long_term_reference(), 0); + assert_eq!(vk.setup_ref.PicOrderCntVal, 2); + } + + #[test] + fn a_failed_conversion_leaves_the_slot_map_untouched_and_the_session_recovers() { + // A right-sized map that never saw the reference's AU, holding one + // unrelated slot: the reference must fail loudly, not resolve to a + // fabricated slot. + let mut slots = SlotMap::new(4); + slots.assign(999).unwrap(); + + let plan = mini_plan( + 5, + 1, + RpsPlan { + st_curr_before: vec![st_ref(4, 0)], + st_curr_after: Vec::new(), + lt_curr: Vec::new(), + }, + vec![3], // a removal that must NOT be applied on the failed path + 4, + ); + assert_eq!( + plan_to_vk_h265(&plan, &mut slots).unwrap_err(), + PlanToVkH265Error::UnresolvedReference(4) + ); + + // Atomicity: the failed conversion mutated nothing. + assert_eq!(slots.active(), 1); + assert_eq!(slots.held().collect::>(), vec![(0, 999)]); + + // And the session recovers: the next valid plan (an IDR restart whose + // `removed` names ids this map never assigned — tolerated by design) + // still converts on the same map. + let idr = mini_plan(6, 0, RpsPlan::default(), vec![4, 5], 4); + let vk = plan_to_vk_h265(&idr, &mut slots).unwrap(); + assert_eq!(vk.setup_slot, 1, "the lowest free slot after the held one"); + assert_eq!(slots.active(), 2); + } + + #[test] + fn an_sps_switch_that_resizes_the_dpb_is_a_capacity_mismatch_not_a_guess() { + // The map was built for a 6-deep DPB; a renegotiated stream plans with + // 16. Refuse, so the session rebuilds session + map instead of handing + // out slots the image pool does not have. + let mut slots = SlotMap::new(6); + plan_to_vk_h265( + &mini_plan(0, 0, RpsPlan::default(), Vec::new(), 6), + &mut slots, + ) + .unwrap(); + + let renegotiated = mini_plan(1, 0, RpsPlan::default(), Vec::new(), 16); + assert_eq!( + plan_to_vk_h265(&renegotiated, &mut slots).unwrap_err(), + PlanToVkH265Error::CapacityMismatch { + required: 17, + capacity: 7 + } + ); + // And the mismatch mutated nothing. + assert_eq!(slots.active(), 1); + } + + #[test] + fn empty_and_flush_shaped_plans_are_rejected_with_typed_errors() { + let mut slots = SlotMap::new(4); + + let mut no_slices = mini_plan(0, 0, RpsPlan::default(), Vec::new(), 4); + no_slices.slices.clear(); + assert_eq!( + plan_to_vk_h265(&no_slices, &mut slots).unwrap_err(), + PlanToVkH265Error::NoSlices + ); + + let mut no_stored = mini_plan(0, 0, RpsPlan::default(), Vec::new(), 4); + no_stored.dpb.stored = None; + assert_eq!( + plan_to_vk_h265(&no_stored, &mut slots).unwrap_err(), + PlanToVkH265Error::NoStoredId + ); + + let mut huge_offset = mini_plan(0, 0, RpsPlan::default(), Vec::new(), 4); + huge_offset.slices[0].data = (u32::MAX as usize + 1)..(u32::MAX as usize + 40); + assert_eq!( + plan_to_vk_h265(&huge_offset, &mut slots).unwrap_err(), + PlanToVkH265Error::OffsetOverflow(u32::MAX as usize + 1) + ); + assert_eq!(slots.active(), 0, "every rejection left the map untouched"); + } + + #[test] + fn an_rps_set_deeper_than_the_std_index_arrays_is_rejected_not_truncated() { + let mut slots = SlotMap::new(16); + for id in 0..9u64 { + slots.assign(id).unwrap(); + } + let deep: Vec = (0..9).map(|i| st_ref(i, i as i32)).collect(); + let plan = mini_plan( + 20, + 9, + RpsPlan { + st_curr_before: deep, + st_curr_after: Vec::new(), + lt_curr: Vec::new(), + }, + Vec::new(), + 16, + ); + assert_eq!( + plan_to_vk_h265(&plan, &mut slots).unwrap_err(), + PlanToVkH265Error::RpsSetOverflow { + set: "RefPicSetStCurrBefore", + len: 9 + } + ); + assert_eq!(slots.active(), 9, "the rejection mutated nothing"); + } + + #[test] + fn a_slice_list_entry_outside_the_rps_sets_fails_closed() { + let mut slots = SlotMap::new(4); + slots.assign(1).unwrap(); + slots.assign(2).unwrap(); + let mut plan = mini_plan( + 3, + 2, + RpsPlan { + st_curr_before: vec![st_ref(1, 0)], + st_curr_after: Vec::new(), + lt_curr: Vec::new(), + }, + Vec::new(), + 4, + ); + // Id 2 holds a slot but is in NO current set: it would be missing from + // pReferenceSlots, so the hardware could not resolve the list entry. + plan.slices[0].ref_list0.push(st_ref(2, 1)); + assert_eq!( + plan_to_vk_h265(&plan, &mut slots).unwrap_err(), + PlanToVkH265Error::ReferenceOutsideRps(2) + ); + } + + #[test] + fn a_stored_and_evicted_picture_still_gets_a_slot_for_the_decode_itself() { + // The defensive same-plan eviction path (H.264 parity): the stored id + // appears in its own plan's `removed` — the slot exists during the + // decode and is released right after, so the next picture can reuse it. + let mut slots = SlotMap::new(1); // capacity 2 + let mut plan = mini_plan(0, 0, RpsPlan::default(), Vec::new(), 1); + plan.dpb.removed = vec![0]; + let vk = plan_to_vk_h265(&plan, &mut slots).unwrap(); + assert_eq!(vk.setup_slot, 0); + assert_eq!(slots.active(), 0, "released after assignment"); + assert_eq!(slots.slot_of(0), None); + } + + #[test] + fn num_delta_pocs_of_ref_rps_idx_derives_from_the_predicted_inline_rps() { + let mut slots = SlotMap::new(4); + slots.assign(1).unwrap(); + + // The activated SPS carries two candidates; the inline slice RPS + // predicts from the second (delta_idx_minus1 = 0 ⇒ RefRpsIdx = 1). + let mut sps = (*mini_sps()).clone(); + sps.num_short_term_ref_pic_sets = 2; + sps.short_term_ref_pic_set = vec![ + ShortTermRefPicSet { + num_delta_pocs: 3, + ..Default::default() + }, + ShortTermRefPicSet { + num_delta_pocs: 5, + ..Default::default() + }, + ]; + let sps = Rc::new(sps); + let pps = mini_pps(&sps); + + let header = SliceHeader { + short_term_ref_pic_set_sps_flag: false, + curr_rps_idx: 2, // == num_short_term_ref_pic_sets: inline + short_term_ref_pic_set: ShortTermRefPicSet { + inter_ref_pic_set_prediction_flag: true, + delta_idx_minus1: 0, + ..Default::default() + }, + ..Default::default() + }; + + let mut picture = mini_picture(1, 4); + picture.short_term_ref_pic_set_size_bits = 23; + let plan = AuPlan { + picture, + rps: RpsPlan { + st_curr_before: vec![st_ref(1, 0)], + st_curr_after: Vec::new(), + lt_curr: Vec::new(), + }, + slices: vec![SlicePlan { + data: 0..32, + header, + ref_list0: vec![st_ref(1, 0)], + ref_list1: Vec::new(), + }], + dpb: DpbUpdate { + stored: Some(2), + outputs: vec![2], + removed: Vec::new(), + }, + warnings: Vec::new(), + sps: Rc::clone(&sps), + pps: Rc::clone(&pps), + }; + let vk = plan_to_vk_h265(&plan, &mut slots).unwrap(); + assert_eq!( + vk.std_pic.NumDeltaPocsOfRefRpsIdx, 5, + "the SOURCE set's count" + ); + assert_eq!(vk.std_pic.flags.short_term_ref_pic_set_sps_flag(), 0); + assert_eq!(vk.std_pic.NumBitsForSTRefPicSetInSlice, 23); + + // A prediction pointing past the candidate table cannot be derived. + let mut broken = plan.clone(); + { + let header = &mut broken.slices[0].header; + header.short_term_ref_pic_set.delta_idx_minus1 = 2; // RefRpsIdx = -1 + } + broken.dpb.stored = Some(3); + assert_eq!( + plan_to_vk_h265(&broken, &mut slots).unwrap_err(), + PlanToVkH265Error::InvalidRefRpsIdx { + curr_rps_idx: 2, + delta_idx_minus1: 2 + } + ); + } + + #[test] + fn parameter_set_ids_flow_from_the_plans_activated_sets() { + let mut slots = SlotMap::new(4); + let mut sps = (*mini_sps()).clone(); + sps.video_parameter_set_id = 3; + sps.seq_parameter_set_id = 7; + let sps = Rc::new(sps); + let mut pps = (*mini_pps(&sps)).clone(); + pps.pic_parameter_set_id = 9; + pps.seq_parameter_set_id = 7; + let pps = Rc::new(pps); + + let mut plan = mini_plan(0, 0, RpsPlan::default(), Vec::new(), 4); + plan.sps = sps; + plan.pps = pps; + let vk = plan_to_vk_h265(&plan, &mut slots).unwrap(); + assert_eq!(vk.std_pic.sps_video_parameter_set_id, 3); + assert_eq!(vk.std_pic.pps_seq_parameter_set_id, 7); + assert_eq!(vk.std_pic.pps_pic_parameter_set_id, 9); + } + + #[test] + fn a_picture_referenced_by_two_sets_binds_one_slot_listed_once() { + // Concealment can resolve an lsb-masked long-term entry and a + // short-term entry to the SAME stored picture; Vulkan wants each slot + // bound once, with both index arrays pointing at that one entry. + let mut slots = SlotMap::new(4); + slots.assign(1).unwrap(); + let plan = mini_plan( + 2, + 1, + RpsPlan { + st_curr_before: vec![st_ref(1, 0)], + st_curr_after: Vec::new(), + lt_curr: vec![lt_ref(1, 0)], + }, + Vec::new(), + 4, + ); + let vk = plan_to_vk_h265(&plan, &mut slots).unwrap(); + assert_eq!(vk.refs.len(), 1, "one binding for one picture"); + assert_eq!( + vk.std_pic.RefPicSetStCurrBefore[0], + vk.std_pic.RefPicSetLtCurr[0] + ); + // The short-term set bound the picture FIRST, but the LtCurr occurrence + // must still mark the shared binding long-term: hardware treats LT + // references differently (no MV scaling, POC-LSB matching), and an + // LtCurr index into a short-term-marked slot is an internally + // inconsistent DPB the driver may reject or mispredict from. + assert_eq!(vk.refs[0].std.flags.used_for_long_term_reference(), 1); + } +}