feat(client): M6 begins — the libva layouts, measured rather than transcribed

The VAAPI rung's crate, in the shape the other two native rungs established:
everything that can be a pure decision or a pure conversion lives in a
cross-platform crate the ordinary gates run, and only the parts that genuinely
need a device stay behind a platform cfg. This lands the first half of that —
the buffer layouts and the decoder-creation decisions — with the conversion to
follow.

Route: minimal FFI rather than cros-libva. The plan of record permits either
("cros-libva (or minimal FFI)"), and hand-declaring keeps the crate building
and testing on macOS and in the Linux container, which is the property that
made pf-dxvadec's defects findable on a laptop instead of on a box.

The layouts are not eyeballed. A C probe compiled against real libva 2.23.0
headers printed sizeof/alignof/offsetof for every field and set individual
bit-fields to read the resulting word back; those numbers are pinned as const
assertions, so a transcription slip is a compile error rather than a driver
reading the wrong byte. The probe is committed beside them, with the command
that runs it, because evidence that cannot be re-run is a claim.

What the probe settled that a reader would otherwise get wrong: VAPictureH264
is 36 bytes and is embedded 81 times across the two buffers, so its size is
load-bearing for every later offset; the three DEPRECATED FMO fields still
occupy bytes 624..628, and dropping them would shift everything after; and C
bit-fields allocate from the least significant bit on this ABI — proven, since
that is ABI-defined rather than standardised.

Groundwork for the conversion, established here so the next work package
starts from facts:

slice_data_bit_offset needs no new parsing. VAAPI is the only backend that
wants a bit position — DXVA takes a byte offset, Vulkan takes none — and the
vendored parser already records exactly it as SliceHeader::header_bit_size,
computed as (nalu.size - epb) * 8 - bits_left: from and including the NAL
header byte, emulation-prevention bytes removed. That is the field's
definition verbatim, and it is there because cros-codecs' own production
backend is VAAPI.

The slice data buffer starts at the NAL header byte, so the start code is
skipped — SlicePlan::data is start-code-inclusive and the prefix is three OR
four bytes, the host emitting four on 100% of access units.

reference_frames is the marked DPB, the same statement DXVA's RefFrameList
makes, so it comes from the dpb_refs snapshot; Vulkan's pReferenceSlots is the
opposite and takes the access unit's own set. All three conventions now have a
written home, which is the distinction that cost M5 a defect.

Unlike DXVA short-format, VAAPI wants the per-slice reference lists and the
full prediction weight tables inline — hence the 3128-byte slice record. One
wrinkle recorded rather than left to be discovered: the vendored
PredWeightTable stores luma_offset_l0 as [i8; 32] but luma_offset_l1 as
[i16; 32], and libva wants i16 for both.

Profile selection resolves H.264 to High for every 8-bit 4:2:0 stream instead
of reading profile_idc, because High is a superset for the tools our hosts
emit and picking Main for a stream that turns out to use 8x8 transforms is a
mid-stream failure where picking High is not. 4:4:4 and 10-bit H.264 are
refused rather than narrowed to an 8-bit profile — that class of silent
narrowing decodes to garbage instead of failing.

11 tests: the probe's bit patterns, a disjointness check per bit-field word
(two probe vectors alone would not catch a shift typo that overlapped two
fields), and the envelope refusals. Gates: rustfmt, clippy, cargo doc with no
unresolved links, and the Linux container's clippy -D warnings, tests and
workspace check.
This commit is contained in:
2026-08-06 13:04:07 +02:00
parent 1573a9876b
commit d3e000768d
7 changed files with 982 additions and 0 deletions
Generated
+8
View File
@@ -3195,6 +3195,14 @@ dependencies = [
"ureq",
]
[[package]]
name = "pf-vaadec"
version = "0.24.0"
dependencies = [
"pf-bitstream",
"pf-vkdecode",
]
[[package]]
name = "pf-vdisplay"
version = "0.24.0"
+1
View File
@@ -27,6 +27,7 @@ members = [
"crates/pf-vdisplay",
"crates/pf-vkdecode",
"crates/pf-dxvadec",
"crates/pf-vaadec",
"crates/pyrowave-sys",
"crates/libvpl-sys",
"clients/probe",
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "pf-vaadec"
description = "Native VAAPI H.264/HEVC decode for the Linux clients (M6): the hand-declared libva decode buffer layouts plus the profile/format/surface decisions — the CPU-testable half; the libva plumbing lives in pf-client-core (design/client-native-decode.md §3.4)"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
repository.workspace = true
[dependencies]
pf-bitstream = { path = "../pf-bitstream" }
# For `SlotMap`/`SlotError` ONLY — see this crate's lib.rs for why the DPB slot ledger is
# borrowed from the Vulkan crate rather than duplicated or moved.
pf-vkdecode = { path = "../pf-vkdecode" }
[lints]
workspace = true
+115
View File
@@ -0,0 +1,115 @@
/*
* Layout probe for the hand-declared libva structures in `src/va.rs`.
*
* `src/va.rs` declares VAAPI's decode buffers as `#[repr(C)]` Rust structs, because
* this crate must build on macOS and in the Linux container where libva headers need
* not exist. This file is how those declarations were CHECKED rather than eyeballed,
* and it is committed so the check is reproducible instead of a claim in a commit
* message.
*
* Run it against real headers (no Linux box required):
*
* docker run --rm --platform linux/amd64 -v "$PWD/crates/pf-vaadec:/w" -w /w \
* pf-lxcheck2 bash -lc 'apt-get update -qq && apt-get install -y -qq libva-dev \
* && gcc -w -O0 layout-probe.c -o /tmp/probe && /tmp/probe'
*
* Every number it prints is pinned as a `const` assertion at the bottom of
* `src/va.rs`, so a transcription mistake is a compile error. The bit-field section
* exists because C bit-field allocation order is ABI-defined, not standardised:
* it PROVES least-significant-bit-first on this ABI rather than assuming it.
*
* Last run against libva 2.23.0-1ubuntu1, x86_64-linux-gnu.
*/
#include <stdio.h>
#include <stddef.h>
#include <va/va.h>
#define S(t) printf("size %-34s %zu align %zu\n", #t, sizeof(t), _Alignof(t))
#define O(t, f) printf("off %-20s %-28s %zu\n", #t, #f, offsetof(t, f))
int main(void) {
S(VAPictureH264);
O(VAPictureH264, picture_id);
O(VAPictureH264, frame_idx);
O(VAPictureH264, flags);
O(VAPictureH264, TopFieldOrderCnt);
O(VAPictureH264, BottomFieldOrderCnt);
O(VAPictureH264, va_reserved);
S(VAPictureParameterBufferH264);
O(VAPictureParameterBufferH264, CurrPic);
O(VAPictureParameterBufferH264, ReferenceFrames);
O(VAPictureParameterBufferH264, picture_width_in_mbs_minus1);
O(VAPictureParameterBufferH264, picture_height_in_mbs_minus1);
O(VAPictureParameterBufferH264, bit_depth_luma_minus8);
O(VAPictureParameterBufferH264, bit_depth_chroma_minus8);
O(VAPictureParameterBufferH264, num_ref_frames);
O(VAPictureParameterBufferH264, seq_fields);
O(VAPictureParameterBufferH264, num_slice_groups_minus1);
O(VAPictureParameterBufferH264, slice_group_map_type);
O(VAPictureParameterBufferH264, slice_group_change_rate_minus1);
O(VAPictureParameterBufferH264, pic_init_qp_minus26);
O(VAPictureParameterBufferH264, pic_init_qs_minus26);
O(VAPictureParameterBufferH264, chroma_qp_index_offset);
O(VAPictureParameterBufferH264, second_chroma_qp_index_offset);
O(VAPictureParameterBufferH264, pic_fields);
O(VAPictureParameterBufferH264, frame_num);
O(VAPictureParameterBufferH264, va_reserved);
S(VAIQMatrixBufferH264);
O(VAIQMatrixBufferH264, ScalingList4x4);
O(VAIQMatrixBufferH264, ScalingList8x8);
O(VAIQMatrixBufferH264, va_reserved);
S(VASliceParameterBufferH264);
O(VASliceParameterBufferH264, slice_data_size);
O(VASliceParameterBufferH264, slice_data_offset);
O(VASliceParameterBufferH264, slice_data_flag);
O(VASliceParameterBufferH264, slice_data_bit_offset);
O(VASliceParameterBufferH264, first_mb_in_slice);
O(VASliceParameterBufferH264, slice_type);
O(VASliceParameterBufferH264, direct_spatial_mv_pred_flag);
O(VASliceParameterBufferH264, num_ref_idx_l0_active_minus1);
O(VASliceParameterBufferH264, num_ref_idx_l1_active_minus1);
O(VASliceParameterBufferH264, cabac_init_idc);
O(VASliceParameterBufferH264, slice_qp_delta);
O(VASliceParameterBufferH264, disable_deblocking_filter_idc);
O(VASliceParameterBufferH264, slice_alpha_c0_offset_div2);
O(VASliceParameterBufferH264, slice_beta_offset_div2);
O(VASliceParameterBufferH264, RefPicList0);
O(VASliceParameterBufferH264, RefPicList1);
O(VASliceParameterBufferH264, luma_log2_weight_denom);
O(VASliceParameterBufferH264, chroma_log2_weight_denom);
O(VASliceParameterBufferH264, luma_weight_l0_flag);
O(VASliceParameterBufferH264, luma_weight_l0);
O(VASliceParameterBufferH264, luma_offset_l0);
O(VASliceParameterBufferH264, chroma_weight_l0_flag);
O(VASliceParameterBufferH264, chroma_weight_l0);
O(VASliceParameterBufferH264, chroma_offset_l0);
O(VASliceParameterBufferH264, luma_weight_l1_flag);
O(VASliceParameterBufferH264, luma_weight_l1);
O(VASliceParameterBufferH264, luma_offset_l1);
O(VASliceParameterBufferH264, chroma_weight_l1_flag);
O(VASliceParameterBufferH264, chroma_weight_l1);
O(VASliceParameterBufferH264, chroma_offset_l1);
O(VASliceParameterBufferH264, va_reserved);
/* Bit-field allocation order: prove LSB-first rather than assume it. */
{
VAPictureParameterBufferH264 p;
p.seq_fields.value = 0;
p.seq_fields.bits.chroma_format_idc = 3;
printf("bits seq_fields.chroma_format_idc=3 -> value 0x%08x\n", p.seq_fields.value);
p.seq_fields.value = 0;
p.seq_fields.bits.log2_max_frame_num_minus4 = 0xf;
printf("bits seq_fields.log2_max_frame_num_minus4=0xf -> value 0x%08x\n", p.seq_fields.value);
p.pic_fields.value = 0;
p.pic_fields.bits.reference_pic_flag = 1;
printf("bits pic_fields.reference_pic_flag=1 -> value 0x%08x\n", p.pic_fields.value);
p.pic_fields.value = 0;
p.pic_fields.bits.weighted_bipred_idc = 3;
printf("bits pic_fields.weighted_bipred_idc=3 -> value 0x%08x\n", p.pic_fields.value);
}
printf("VA_PADDING_LOW=%d VA_PADDING_MEDIUM=%d\n", VA_PADDING_LOW, VA_PADDING_MEDIUM);
return 0;
}
+174
View File
@@ -0,0 +1,174 @@
//! Decoder-creation decisions: which `VAProfile` a stream needs, which render-target
//! format its surfaces must carry, and how many of them to allocate.
//!
//! The same job `pf-dxvadec`'s `config` module does for DXVA, and split out for the same
//! reason: these are pure functions of the stream's shape, so they belong where the
//! ordinary gates run them rather than inside `cfg(target_os = "linux")` FFI that
//! only a box can compile.
//!
//! Constant values are the libva 2.23.0 enumerators.
/// `VAEntrypointVLD` — full bitstream decode, the only entry point this rung uses.
pub const VA_ENTRYPOINT_VLD: u32 = 1;
/// `VAProfile` enumerators (`va.h`).
pub const VA_PROFILE_H264_MAIN: i32 = 6;
pub const VA_PROFILE_H264_HIGH: i32 = 7;
pub const VA_PROFILE_H264_CONSTRAINED_BASELINE: i32 = 13;
pub const VA_PROFILE_HEVC_MAIN: i32 = 17;
pub const VA_PROFILE_HEVC_MAIN10: i32 = 18;
/// `VA_RT_FORMAT_*` — the surface render-target format.
pub const VA_RT_FORMAT_YUV420: u32 = 0x0000_0001;
pub const VA_RT_FORMAT_YUV444: u32 = 0x0000_0004;
pub const VA_RT_FORMAT_YUV420_10: u32 = 0x0000_0100;
/// Which codec a session decodes. Mirrors `pf-dxvadec`'s `Codec` rather than
/// re-exporting it: this crate must not depend on the Windows-facing one.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Codec {
H264,
H265,
}
/// A profile choice, with the name the logs print.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaProfile {
pub value: i32,
pub name: &'static str,
}
/// Why a stream cannot be decoded by this rung at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigError {
/// A (chroma, depth) pair with no profile — 4:4:4, or a depth outside 8/10.
UnsupportedShape { chroma_format_idc: u8, depth: u8 },
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigError::UnsupportedShape {
chroma_format_idc,
depth,
} => write!(
f,
"no VAAPI decode profile for chroma_format_idc {chroma_format_idc} at {depth} bits"
),
}
}
}
impl std::error::Error for ConfigError {}
/// The profile a stream of this shape decodes under.
///
/// H.264 resolves to **High** for every 8-bit 4:2:0 stream rather than reading the
/// SPS's `profile_idc`. That is deliberate and is what VAAPI clients do: High is a
/// superset of Main and Constrained Baseline for the tools our hosts emit, every
/// driver advertising H.264 decode advertises High, and picking Main for a stream
/// that turns out to use 8x8 transforms is a mid-stream failure where picking High
/// is not. The narrower enumerators are exported for the capability probe, which
/// reports what the DEVICE offers.
///
/// 4:4:4 is refused rather than mapped: `VAProfileH264High444` exists in the header
/// but no driver in this fleet advertises it, and the Vulkan rung is where this
/// program's 4:4:4 support actually lives.
pub fn profile_for(
codec: Codec,
chroma_format_idc: u8,
depth: u8,
) -> Result<VaProfile, ConfigError> {
match (codec, chroma_format_idc, depth) {
(Codec::H264, 1, 8) => Ok(VaProfile {
value: VA_PROFILE_H264_HIGH,
name: "H.264 High",
}),
(Codec::H265, 1, 8) => Ok(VaProfile {
value: VA_PROFILE_HEVC_MAIN,
name: "HEVC Main",
}),
(Codec::H265, 1, 10) => Ok(VaProfile {
value: VA_PROFILE_HEVC_MAIN10,
name: "HEVC Main 10",
}),
_ => Err(ConfigError::UnsupportedShape {
chroma_format_idc,
depth,
}),
}
}
/// The surface render-target format for a stream shape.
pub fn rt_format(chroma_format_idc: u8, depth: u8) -> Result<u32, ConfigError> {
match (chroma_format_idc, depth) {
(1, 8) => Ok(VA_RT_FORMAT_YUV420),
(1, 10) => Ok(VA_RT_FORMAT_YUV420_10),
(3, 8) => Ok(VA_RT_FORMAT_YUV444),
_ => Err(ConfigError::UnsupportedShape {
chroma_format_idc,
depth,
}),
}
}
/// Headroom over the DPB for pictures the presenter still holds.
///
/// A surface handed to the compositor is not free to decode into, and a pool sized
/// exactly to the DPB stalls the decoder behind the display.
pub const PRESENTER_HEADROOM: usize = 4;
/// How many decode surfaces a session allocates: the DPB, plus the picture being
/// decoded, plus [`PRESENTER_HEADROOM`].
///
/// VAAPI reports no driver minimum to honour (DXVA's
/// `ConfigMinRenderTargetBuffCount` has no counterpart), so this is the whole rule.
pub fn surface_count(max_dpb_frames: usize) -> usize {
max_dpb_frames + 1 + PRESENTER_HEADROOM
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn h264_8bit_420_is_high() {
let p = profile_for(Codec::H264, 1, 8).expect("the envelope's only H.264 shape");
assert_eq!(p.value, VA_PROFILE_H264_HIGH);
}
#[test]
fn hevc_picks_main_or_main10_by_depth() {
assert_eq!(
profile_for(Codec::H265, 1, 8).unwrap().value,
VA_PROFILE_HEVC_MAIN
);
assert_eq!(
profile_for(Codec::H265, 1, 10).unwrap().value,
VA_PROFILE_HEVC_MAIN10
);
}
#[test]
fn shapes_outside_the_envelope_are_refused_not_guessed() {
// 10-bit H.264 (High10) and 4:4:4 both have header enumerators; neither is
// in this rung's envelope, and silently narrowing to an 8-bit profile is the
// class of bug that decodes to garbage instead of failing.
assert!(profile_for(Codec::H264, 1, 10).is_err());
assert!(profile_for(Codec::H264, 3, 8).is_err());
assert!(profile_for(Codec::H265, 3, 10).is_err());
assert!(rt_format(1, 12).is_err());
}
#[test]
fn rt_format_tracks_depth() {
assert_eq!(rt_format(1, 8).unwrap(), VA_RT_FORMAT_YUV420);
assert_eq!(rt_format(1, 10).unwrap(), VA_RT_FORMAT_YUV420_10);
}
#[test]
fn the_surface_pool_covers_dpb_plus_current_plus_headroom() {
assert_eq!(surface_count(4), 9);
assert_eq!(surface_count(16), 21);
}
}
+91
View File
@@ -0,0 +1,91 @@
//! Native VAAPI decode for the Linux clients — M6 of the native-decode program, and
//! the VAAPI counterpart of [`pf_vkdecode`] and `pf-dxvadec`.
//!
//! Like `pf-dxvadec`, this crate is the **CPU-testable half**: everything between
//! pf-bitstream's per-AU plan and the buffers a `vaRenderPicture` call delivers. It
//! links no libva, names no `VA*` handle type, and compiles on macOS and in the Linux
//! container — which is the point. The VAAPI rung itself is
//! `cfg(target_os = "linux")` code that only a box can build, so anything left inside
//! that boundary is verified by a remote `cargo check` and nothing more.
//!
//! - [`va`]: the libva decode buffer layouts, **hand-declared**, with every size and
//! offset measured off the real headers and pinned as compile-time assertions.
//! - [`config`]: profile, render-target format and surface-count decisions.
//!
//! # Status
//!
//! **Layouts and configuration only.** The `AuPlan` → picture/slice/IQ-matrix
//! conversion is the next work package; the groundwork it needs is established and
//! recorded here so it starts from facts rather than from a reading of the spec:
//!
//! * **`slice_data_bit_offset` costs no new parsing.** VAAPI is the only one of the
//! three backends that wants a bit position — DXVA takes a byte offset, Vulkan
//! takes none — and the vendored parser already records exactly it as
//! `SliceHeader::header_bit_size`, because cros-codecs' own production backend is
//! VAAPI. Its definition matches field for field: computed as
//! `(nalu.size - emulation_prevention_bytes) * 8 - bits_left`, it counts from and
//! including the NAL header byte with emulation-prevention bytes removed, which is
//! what `VASliceParameterBufferH264` documents.
//! * **The slice data buffer starts at the NAL header byte**, so the start code must
//! be skipped — `SlicePlan::data` is start-code-inclusive, and the prefix is three
//! OR four bytes (the real host emits four on 100% of access units). The same
//! normalisation the Vulkan ring layer performs, and the reason that layer exists.
//! * **`VAPictureParameterBufferH264::reference_frames` is the MARKED DPB**, not the
//! access unit's own lists — the same statement DXVA's `RefFrameList` makes, so it
//! is filled from pf-bitstream's per-AU `dpb_refs` snapshot. Vulkan's
//! `pReferenceSlots` is the opposite and takes the AU's own set; all three
//! conventions now have a written home.
//! * **Unlike DXVA's short-format slice control, VAAPI wants the per-slice reference
//! lists themselves** (`RefPicList0`/`RefPicList1`, 32 entries each, in 8.2.4.2
//! order) and the full prediction weight tables. `SlicePlan::ref_list0`/`ref_list1`
//! and `SliceHeader::pred_weight_table` supply both — with one wrinkle to handle
//! rather than discover later: the vendored `PredWeightTable` stores
//! `luma_offset_l0` as `[i8; 32]` but `luma_offset_l1` as `[i16; 32]`, an upstream
//! inconsistency, while libva wants `i16` for both.
//!
//! # Why the slot ledger is borrowed
//!
//! [`SlotMap`] comes from [`pf_vkdecode`] for the reason `pf-dxvadec`'s docs give at
//! length: it is not a Vulkan object but a ledger from
//! [`pf_bitstream::h264::PicId`] to hardware DPB slot indices, and it is as
//! API-agnostic as it is codec-agnostic. VAAPI's own indirection is one step longer —
//! a slot indexes the caller's surface table, because `VAPictureH264::picture_id` is
//! a `VASurfaceID` rather than an index — so the conversion will take that table as
//! a parameter and stay pure.
pub mod config;
pub mod va;
/// The DPB slot ledger — borrowed, not redefined (crate docs).
pub use pf_vkdecode::SlotError;
pub use pf_vkdecode::SlotMap;
/// The planners and plans this crate converts, re-exported so the Linux layer names
/// every type it touches through `pf_vaadec` — the same courtesy `pf-dxvadec` does
/// for the Windows layer.
pub use pf_bitstream::h264::AuPlan;
pub use pf_bitstream::h264::H264Planner;
pub use pf_bitstream::h264::PlanError;
pub use pf_bitstream::h264::PlanWarning;
pub use pf_bitstream::h265::AuPlan as AuPlanH265;
pub use pf_bitstream::h265::H265Planner;
pub use pf_bitstream::h265::PlanError as PlanErrorH265;
pub use pf_bitstream::h265::PlanWarning as PlanWarningH265;
/// Which warnings mean the PICTURE is damaged — pf-vkdecode's one list, so all three
/// native rungs conceal on exactly the same predicate.
pub use pf_vkdecode::is_integrity_warning;
pub use pf_vkdecode::is_integrity_warning_h265;
pub use config::profile_for;
pub use config::rt_format;
pub use config::surface_count;
pub use config::Codec;
pub use config::ConfigError;
pub use config::VaProfile;
pub use config::VA_ENTRYPOINT_VLD;
pub use va::PicFieldsH264;
pub use va::SeqFieldsH264;
pub use va::VaIqMatrixBufferH264;
pub use va::VaPictureH264;
pub use va::VaPictureParameterBufferH264;
pub use va::VaSliceParameterBufferH264;
+575
View File
@@ -0,0 +1,575 @@
//! The libva decode buffer layouts for H.264, **hand-declared**.
//!
//! There is no libva binding in this workspace and this crate deliberately does not
//! introduce one: it must compile and be tested on macOS and in the Linux container,
//! where `libva` headers need not exist at all. So the structures VAAPI reads are
//! declared here as plain `#[repr(C)]` PODs, exactly as `pf-dxvadec`'s `dxva` module declares
//! DXVA's — same reasoning, same discipline.
//!
//! # These are not eyeballed
//!
//! Every size, every field offset and the bit-field allocation order below were
//! measured against the real headers (libva **2.23.0**, `x86_64-linux-gnu`) by
//! compiling a probe that printed `sizeof`, `_Alignof` and `offsetof` for each field
//! and set individual bit-fields to read the resulting word back. The numbers that
//! probe produced are pinned as `const` assertions at the bottom of this module, so
//! a mistake here is a compile error rather than a driver reading the wrong byte.
//!
//! The measured facts worth stating in prose, because they are the ones a reader
//! would otherwise assume wrongly:
//!
//! * `VAPictureH264` is **36** bytes — five 4-byte fields plus `VA_PADDING_LOW`
//! (4 × `uint32_t`) of reserved tail. It is embedded 1 + 16 times in the picture
//! parameter buffer and 64 times in the slice parameter buffer, so its size being
//! right is load-bearing for every offset after it.
//! * `VAPictureParameterBufferH264` is **672** bytes, `VAIQMatrixBufferH264` **240**,
//! and `VASliceParameterBufferH264` **3128** — the last one because it carries two
//! 32-entry reference lists *and* the full prediction weight tables inline.
//! * The three deprecated FMO fields (`num_slice_groups_minus1`,
//! `slice_group_map_type`, `slice_group_change_rate_minus1`) still occupy bytes
//! 624..628. Deprecated does not mean absent: dropping them would shift every
//! later field. They are declared, and always zero.
//! * C bit-fields on this ABI allocate from the **least significant bit**, proven
//! rather than assumed: setting `log2_max_frame_num_minus4` (the 4 bits declared
//! after eight single-bit flags and a 2-bit field) to `0xf` yields `0x0000_0f00`,
//! and `weighted_bipred_idc = 3` yields `0x0000_000c`.
//!
//! # Surface identity
//!
//! `VAPictureH264::picture_id` is a `VASurfaceID`, not a slot index — unlike DXVA,
//! where the surface index and the DPB slot are the same number by construction.
//! This crate never invents one: the conversion (`plan_to_va`) takes the caller's
//! slot → `VASurfaceID` table and indexes it, so the Linux layer owns surface
//! allocation and this half stays pure.
/// `VA_INVALID_SURFACE` — what an unused `ReferenceFrames` / `RefPicList` entry
/// carries. Paired with [`VA_PICTURE_H264_INVALID`]; drivers key on the flag, but a
/// stale surface id in an "invalid" entry is the kind of thing that decodes fine on
/// one vendor and not another, so both are always written together.
pub const VA_INVALID_SURFACE: u32 = 0xffff_ffff;
/// Flags for [`VaPictureH264::flags`].
pub const VA_PICTURE_H264_INVALID: u32 = 0x0000_0001;
pub const VA_PICTURE_H264_TOP_FIELD: u32 = 0x0000_0002;
pub const VA_PICTURE_H264_BOTTOM_FIELD: u32 = 0x0000_0004;
pub const VA_PICTURE_H264_SHORT_TERM_REFERENCE: u32 = 0x0000_0008;
pub const VA_PICTURE_H264_LONG_TERM_REFERENCE: u32 = 0x0000_0010;
/// `VA_SLICE_DATA_FLAG_ALL` — this buffer holds the whole slice, which is the only
/// shape we submit (the wire delivers complete access units; nothing here streams a
/// slice in fragments).
pub const VA_SLICE_DATA_FLAG_ALL: u32 = 0x00;
/// `VAPictureH264` — one DPB entry, or the current picture.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaPictureH264 {
/// `VASurfaceID` of the decode surface holding this picture.
pub picture_id: u32,
/// `frame_num` for a short-term reference, `LongTermFrameIdx` for a long-term
/// one — the same pair DXVA and Vulkan key references by, which is why
/// [`pf_bitstream::h264::RefPic`] already carries exactly this.
pub frame_idx: u32,
pub flags: u32,
pub top_field_order_cnt: i32,
pub bottom_field_order_cnt: i32,
/// `va_reserved[VA_PADDING_LOW]` — "must be zero".
pub va_reserved: [u32; 4],
}
impl VaPictureH264 {
/// The entry an unused reference slot carries: invalid flag AND invalid surface.
pub const fn invalid() -> Self {
VaPictureH264 {
picture_id: VA_INVALID_SURFACE,
frame_idx: 0,
flags: VA_PICTURE_H264_INVALID,
top_field_order_cnt: 0,
bottom_field_order_cnt: 0,
va_reserved: [0; 4],
}
}
}
/// `VAPictureParameterBufferH264::seq_fields`, unpacked.
///
/// Declared as its own type rather than as a bare `u32` so the bit layout lives
/// beside the structure it belongs to and can be unit-tested on its own; [`Self::pack`]
/// is the only place the shifts appear.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SeqFieldsH264 {
pub chroma_format_idc: u8,
/// `residual_colour_transform_flag` in the header's older spelling; the standard
/// renamed it `separate_colour_plane_flag`.
pub separate_colour_plane_flag: bool,
pub gaps_in_frame_num_value_allowed_flag: bool,
pub frame_mbs_only_flag: bool,
pub mb_adaptive_frame_field_flag: bool,
pub direct_8x8_inference_flag: bool,
/// A.3.3.2 — level-derived, not an SPS syntax element.
pub min_luma_bi_pred_size8x8: bool,
pub log2_max_frame_num_minus4: u8,
pub pic_order_cnt_type: u8,
pub log2_max_pic_order_cnt_lsb_minus4: u8,
pub delta_pic_order_always_zero_flag: bool,
}
impl SeqFieldsH264 {
/// Pack to the `uint32_t` the union aliases. Bit positions are the measured
/// allocation order (module docs), LSB first, in declaration order.
pub const fn pack(self) -> u32 {
(self.chroma_format_idc as u32 & 0x3)
| ((self.separate_colour_plane_flag as u32) << 2)
| ((self.gaps_in_frame_num_value_allowed_flag as u32) << 3)
| ((self.frame_mbs_only_flag as u32) << 4)
| ((self.mb_adaptive_frame_field_flag as u32) << 5)
| ((self.direct_8x8_inference_flag as u32) << 6)
| ((self.min_luma_bi_pred_size8x8 as u32) << 7)
| ((self.log2_max_frame_num_minus4 as u32 & 0xf) << 8)
| ((self.pic_order_cnt_type as u32 & 0x3) << 12)
| ((self.log2_max_pic_order_cnt_lsb_minus4 as u32 & 0xf) << 14)
| ((self.delta_pic_order_always_zero_flag as u32) << 18)
}
}
/// `VAPictureParameterBufferH264::pic_fields`, unpacked. See [`SeqFieldsH264`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PicFieldsH264 {
pub entropy_coding_mode_flag: bool,
pub weighted_pred_flag: bool,
pub weighted_bipred_idc: u8,
pub transform_8x8_mode_flag: bool,
pub field_pic_flag: bool,
pub constrained_intra_pred_flag: bool,
/// `bottom_field_pic_order_in_frame_present_flag` in current spec spelling.
pub pic_order_present_flag: bool,
pub deblocking_filter_control_present_flag: bool,
pub redundant_pic_cnt_present_flag: bool,
/// `nal_ref_idc != 0` — a statement about THIS picture, not the PPS.
pub reference_pic_flag: bool,
}
impl PicFieldsH264 {
pub const fn pack(self) -> u32 {
(self.entropy_coding_mode_flag as u32)
| ((self.weighted_pred_flag as u32) << 1)
| ((self.weighted_bipred_idc as u32 & 0x3) << 2)
| ((self.transform_8x8_mode_flag as u32) << 4)
| ((self.field_pic_flag as u32) << 5)
| ((self.constrained_intra_pred_flag as u32) << 6)
| ((self.pic_order_present_flag as u32) << 7)
| ((self.deblocking_filter_control_present_flag as u32) << 8)
| ((self.redundant_pic_cnt_present_flag as u32) << 9)
| ((self.reference_pic_flag as u32) << 10)
}
}
/// `VAPictureParameterBufferH264` — one per picture, before any slice data.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaPictureParameterBufferH264 {
pub curr_pic: VaPictureH264,
/// The DPB, not this AU's reference lists. VAAPI documents this as "in DPB",
/// which is the same statement DXVA's `RefFrameList` makes and the opposite of
/// Vulkan's `pReferenceSlots` — so it is filled from pf-bitstream's per-AU
/// `dpb_refs` snapshot, the accessor M5 added for exactly this distinction.
pub reference_frames: [VaPictureH264; 16],
pub picture_width_in_mbs_minus1: u16,
pub picture_height_in_mbs_minus1: u16,
pub bit_depth_luma_minus8: u8,
pub bit_depth_chroma_minus8: u8,
pub num_ref_frames: u8,
/// Packed [`SeqFieldsH264`]. (One byte of padding precedes it — `num_ref_frames`
/// ends at 619 and the union is 4-aligned at 620.)
pub seq_fields: u32,
/// Deprecated FMO fields. Still occupy bytes 624..628; always zero here, and
/// the conversion (`plan_to_va`) refuses a stream that uses slice groups rather
/// than silently ignoring them.
pub num_slice_groups_minus1: u8,
pub slice_group_map_type: u8,
pub slice_group_change_rate_minus1: u16,
pub pic_init_qp_minus26: i8,
pub pic_init_qs_minus26: i8,
pub chroma_qp_index_offset: i8,
pub second_chroma_qp_index_offset: i8,
/// Packed [`PicFieldsH264`].
pub pic_fields: u32,
pub frame_num: u16,
/// `va_reserved[VA_PADDING_MEDIUM]`. Two bytes of padding precede it (`frame_num`
/// ends at 638, the array is 4-aligned at 640).
pub va_reserved: [u32; 8],
}
/// `VAIQMatrixBufferH264` — both scaling list sets, raster scan order.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaIqMatrixBufferH264 {
pub scaling_list4x4: [[u8; 16]; 6],
pub scaling_list8x8: [[u8; 64]; 2],
pub va_reserved: [u32; 4],
}
/// `VASliceParameterBufferH264` — one per slice NALU.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VaSliceParameterBufferH264 {
pub slice_data_size: u32,
pub slice_data_offset: u32,
pub slice_data_flag: u32,
/// Bit offset from the start of the NAL unit byte to the first bit of
/// `slice_data()`, counted **after emulation-prevention bytes are removed** even
/// though the buffer handed to the driver still contains them.
///
/// Nothing else in this program needs this number: DXVA takes a byte offset to
/// the slice and Vulkan takes none at all. The vendored parser records it as
/// `SliceHeader::header_bit_size` because its own production backend is VAAPI,
/// so it costs no new parsing — see the crate docs.
pub slice_data_bit_offset: u16,
pub first_mb_in_slice: u16,
pub slice_type: u8,
pub direct_spatial_mv_pred_flag: u8,
pub num_ref_idx_l0_active_minus1: u8,
pub num_ref_idx_l1_active_minus1: u8,
pub cabac_init_idc: u8,
pub slice_qp_delta: i8,
pub disable_deblocking_filter_idc: u8,
pub slice_alpha_c0_offset_div2: i8,
pub slice_beta_offset_div2: i8,
/// 8.2.4.2 reference lists — the AU's own, unlike
/// [`VaPictureParameterBufferH264::reference_frames`].
pub ref_pic_list0: [VaPictureH264; 32],
pub ref_pic_list1: [VaPictureH264; 32],
pub luma_log2_weight_denom: u8,
pub chroma_log2_weight_denom: u8,
pub luma_weight_l0_flag: u8,
pub luma_weight_l0: [i16; 32],
pub luma_offset_l0: [i16; 32],
pub chroma_weight_l0_flag: u8,
pub chroma_weight_l0: [[i16; 2]; 32],
pub chroma_offset_l0: [[i16; 2]; 32],
pub luma_weight_l1_flag: u8,
pub luma_weight_l1: [i16; 32],
pub luma_offset_l1: [i16; 32],
pub chroma_weight_l1_flag: u8,
pub chroma_weight_l1: [[i16; 2]; 32],
pub chroma_offset_l1: [[i16; 2]; 32],
pub va_reserved: [u32; 4],
}
impl VaSliceParameterBufferH264 {
/// An all-zero record with the reference lists invalidated — the base every
/// slice is built from, so an unwritten entry is never a stale surface id.
pub const fn zeroed() -> Self {
VaSliceParameterBufferH264 {
slice_data_size: 0,
slice_data_offset: 0,
slice_data_flag: VA_SLICE_DATA_FLAG_ALL,
slice_data_bit_offset: 0,
first_mb_in_slice: 0,
slice_type: 0,
direct_spatial_mv_pred_flag: 0,
num_ref_idx_l0_active_minus1: 0,
num_ref_idx_l1_active_minus1: 0,
cabac_init_idc: 0,
slice_qp_delta: 0,
disable_deblocking_filter_idc: 0,
slice_alpha_c0_offset_div2: 0,
slice_beta_offset_div2: 0,
ref_pic_list0: [VaPictureH264::invalid(); 32],
ref_pic_list1: [VaPictureH264::invalid(); 32],
luma_log2_weight_denom: 0,
chroma_log2_weight_denom: 0,
luma_weight_l0_flag: 0,
luma_weight_l0: [0; 32],
luma_offset_l0: [0; 32],
chroma_weight_l0_flag: 0,
chroma_weight_l0: [[0; 2]; 32],
chroma_offset_l0: [[0; 2]; 32],
luma_weight_l1_flag: 0,
luma_weight_l1: [0; 32],
luma_offset_l1: [0; 32],
chroma_weight_l1_flag: 0,
chroma_weight_l1: [[0; 2]; 32],
chroma_offset_l1: [[0; 2]; 32],
va_reserved: [0; 4],
}
}
}
// ---------------------------------------------------------------------------
// Layout proofs — the probe's output, pinned.
//
// libva 2.23.0, x86_64-linux-gnu. A `#[repr(C)]` Rust struct and a C struct agree
// by definition of repr(C), so these assertions are not testing the compiler: they
// are testing that the FIELDS AND THEIR ORDER above match the header, which is the
// part a human transcribed and can get wrong.
// ---------------------------------------------------------------------------
const _: () = {
use std::mem::offset_of;
use std::mem::size_of;
assert!(size_of::<VaPictureH264>() == 36);
assert!(offset_of!(VaPictureH264, picture_id) == 0);
assert!(offset_of!(VaPictureH264, frame_idx) == 4);
assert!(offset_of!(VaPictureH264, flags) == 8);
assert!(offset_of!(VaPictureH264, top_field_order_cnt) == 12);
assert!(offset_of!(VaPictureH264, bottom_field_order_cnt) == 16);
assert!(offset_of!(VaPictureH264, va_reserved) == 20);
assert!(size_of::<VaPictureParameterBufferH264>() == 672);
assert!(offset_of!(VaPictureParameterBufferH264, curr_pic) == 0);
assert!(offset_of!(VaPictureParameterBufferH264, reference_frames) == 36);
assert!(offset_of!(VaPictureParameterBufferH264, picture_width_in_mbs_minus1) == 612);
assert!(offset_of!(VaPictureParameterBufferH264, picture_height_in_mbs_minus1) == 614);
assert!(offset_of!(VaPictureParameterBufferH264, bit_depth_luma_minus8) == 616);
assert!(offset_of!(VaPictureParameterBufferH264, bit_depth_chroma_minus8) == 617);
assert!(offset_of!(VaPictureParameterBufferH264, num_ref_frames) == 618);
assert!(offset_of!(VaPictureParameterBufferH264, seq_fields) == 620);
assert!(offset_of!(VaPictureParameterBufferH264, num_slice_groups_minus1) == 624);
assert!(offset_of!(VaPictureParameterBufferH264, slice_group_map_type) == 625);
assert!(offset_of!(VaPictureParameterBufferH264, slice_group_change_rate_minus1) == 626);
assert!(offset_of!(VaPictureParameterBufferH264, pic_init_qp_minus26) == 628);
assert!(offset_of!(VaPictureParameterBufferH264, pic_init_qs_minus26) == 629);
assert!(offset_of!(VaPictureParameterBufferH264, chroma_qp_index_offset) == 630);
assert!(offset_of!(VaPictureParameterBufferH264, second_chroma_qp_index_offset) == 631);
assert!(offset_of!(VaPictureParameterBufferH264, pic_fields) == 632);
assert!(offset_of!(VaPictureParameterBufferH264, frame_num) == 636);
assert!(offset_of!(VaPictureParameterBufferH264, va_reserved) == 640);
assert!(size_of::<VaIqMatrixBufferH264>() == 240);
assert!(offset_of!(VaIqMatrixBufferH264, scaling_list4x4) == 0);
assert!(offset_of!(VaIqMatrixBufferH264, scaling_list8x8) == 96);
assert!(offset_of!(VaIqMatrixBufferH264, va_reserved) == 224);
assert!(size_of::<VaSliceParameterBufferH264>() == 3128);
assert!(offset_of!(VaSliceParameterBufferH264, slice_data_size) == 0);
assert!(offset_of!(VaSliceParameterBufferH264, slice_data_offset) == 4);
assert!(offset_of!(VaSliceParameterBufferH264, slice_data_flag) == 8);
assert!(offset_of!(VaSliceParameterBufferH264, slice_data_bit_offset) == 12);
assert!(offset_of!(VaSliceParameterBufferH264, first_mb_in_slice) == 14);
assert!(offset_of!(VaSliceParameterBufferH264, slice_type) == 16);
assert!(offset_of!(VaSliceParameterBufferH264, direct_spatial_mv_pred_flag) == 17);
assert!(offset_of!(VaSliceParameterBufferH264, num_ref_idx_l0_active_minus1) == 18);
assert!(offset_of!(VaSliceParameterBufferH264, num_ref_idx_l1_active_minus1) == 19);
assert!(offset_of!(VaSliceParameterBufferH264, cabac_init_idc) == 20);
assert!(offset_of!(VaSliceParameterBufferH264, slice_qp_delta) == 21);
assert!(offset_of!(VaSliceParameterBufferH264, disable_deblocking_filter_idc) == 22);
assert!(offset_of!(VaSliceParameterBufferH264, slice_alpha_c0_offset_div2) == 23);
assert!(offset_of!(VaSliceParameterBufferH264, slice_beta_offset_div2) == 24);
assert!(offset_of!(VaSliceParameterBufferH264, ref_pic_list0) == 28);
assert!(offset_of!(VaSliceParameterBufferH264, ref_pic_list1) == 1180);
assert!(offset_of!(VaSliceParameterBufferH264, luma_log2_weight_denom) == 2332);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_log2_weight_denom) == 2333);
assert!(offset_of!(VaSliceParameterBufferH264, luma_weight_l0_flag) == 2334);
assert!(offset_of!(VaSliceParameterBufferH264, luma_weight_l0) == 2336);
assert!(offset_of!(VaSliceParameterBufferH264, luma_offset_l0) == 2400);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_weight_l0_flag) == 2464);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_weight_l0) == 2466);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_offset_l0) == 2594);
assert!(offset_of!(VaSliceParameterBufferH264, luma_weight_l1_flag) == 2722);
assert!(offset_of!(VaSliceParameterBufferH264, luma_weight_l1) == 2724);
assert!(offset_of!(VaSliceParameterBufferH264, luma_offset_l1) == 2788);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_weight_l1_flag) == 2852);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_weight_l1) == 2854);
assert!(offset_of!(VaSliceParameterBufferH264, chroma_offset_l1) == 2982);
assert!(offset_of!(VaSliceParameterBufferH264, va_reserved) == 3112);
};
#[cfg(test)]
mod tests {
use super::*;
// The three bit patterns the probe read back off real headers. If the shifts
// above are ever "tidied", these fail with the measured value in hand.
#[test]
fn seq_fields_pack_where_the_probe_measured() {
assert_eq!(
SeqFieldsH264 {
chroma_format_idc: 3,
..Default::default()
}
.pack(),
0x0000_0003
);
assert_eq!(
SeqFieldsH264 {
log2_max_frame_num_minus4: 0xf,
..Default::default()
}
.pack(),
0x0000_0f00
);
}
#[test]
fn pic_fields_pack_where_the_probe_measured() {
assert_eq!(
PicFieldsH264 {
reference_pic_flag: true,
..Default::default()
}
.pack(),
0x0000_0400
);
assert_eq!(
PicFieldsH264 {
weighted_bipred_idc: 3,
..Default::default()
}
.pack(),
0x0000_000c
);
}
#[test]
fn every_seq_field_owns_a_distinct_bit_range() {
// Each field set alone must light only its own bits, and the OR of all of
// them must equal the packing of all-at-once: a shift typo that overlapped
// two fields would still pass the two probe vectors above.
let each = [
SeqFieldsH264 {
chroma_format_idc: 3,
..Default::default()
},
SeqFieldsH264 {
separate_colour_plane_flag: true,
..Default::default()
},
SeqFieldsH264 {
gaps_in_frame_num_value_allowed_flag: true,
..Default::default()
},
SeqFieldsH264 {
frame_mbs_only_flag: true,
..Default::default()
},
SeqFieldsH264 {
mb_adaptive_frame_field_flag: true,
..Default::default()
},
SeqFieldsH264 {
direct_8x8_inference_flag: true,
..Default::default()
},
SeqFieldsH264 {
min_luma_bi_pred_size8x8: true,
..Default::default()
},
SeqFieldsH264 {
log2_max_frame_num_minus4: 0xf,
..Default::default()
},
SeqFieldsH264 {
pic_order_cnt_type: 3,
..Default::default()
},
SeqFieldsH264 {
log2_max_pic_order_cnt_lsb_minus4: 0xf,
..Default::default()
},
SeqFieldsH264 {
delta_pic_order_always_zero_flag: true,
..Default::default()
},
];
let mut seen = 0u32;
for f in each {
let bits = f.pack();
assert_ne!(bits, 0, "a field packed to nothing");
assert_eq!(seen & bits, 0, "two fields share a bit: {bits:#010x}");
seen |= bits;
}
let all = SeqFieldsH264 {
chroma_format_idc: 3,
separate_colour_plane_flag: true,
gaps_in_frame_num_value_allowed_flag: true,
frame_mbs_only_flag: true,
mb_adaptive_frame_field_flag: true,
direct_8x8_inference_flag: true,
min_luma_bi_pred_size8x8: true,
log2_max_frame_num_minus4: 0xf,
pic_order_cnt_type: 3,
log2_max_pic_order_cnt_lsb_minus4: 0xf,
delta_pic_order_always_zero_flag: true,
};
assert_eq!(all.pack(), seen);
// Nothing may reach past bit 18 — the last declared bit.
assert_eq!(seen & !0x0007_ffff, 0);
}
#[test]
fn every_pic_field_owns_a_distinct_bit_range() {
let each = [
PicFieldsH264 {
entropy_coding_mode_flag: true,
..Default::default()
},
PicFieldsH264 {
weighted_pred_flag: true,
..Default::default()
},
PicFieldsH264 {
weighted_bipred_idc: 3,
..Default::default()
},
PicFieldsH264 {
transform_8x8_mode_flag: true,
..Default::default()
},
PicFieldsH264 {
field_pic_flag: true,
..Default::default()
},
PicFieldsH264 {
constrained_intra_pred_flag: true,
..Default::default()
},
PicFieldsH264 {
pic_order_present_flag: true,
..Default::default()
},
PicFieldsH264 {
deblocking_filter_control_present_flag: true,
..Default::default()
},
PicFieldsH264 {
redundant_pic_cnt_present_flag: true,
..Default::default()
},
PicFieldsH264 {
reference_pic_flag: true,
..Default::default()
},
];
let mut seen = 0u32;
for f in each {
let bits = f.pack();
assert_ne!(bits, 0);
assert_eq!(seen & bits, 0, "two fields share a bit: {bits:#010x}");
seen |= bits;
}
assert_eq!(seen & !0x0000_07ff, 0);
}
#[test]
fn an_unused_reference_entry_is_invalid_in_both_ways() {
let e = VaPictureH264::invalid();
assert_eq!(e.flags, VA_PICTURE_H264_INVALID);
assert_eq!(e.picture_id, VA_INVALID_SURFACE);
}
#[test]
fn a_zeroed_slice_record_starts_with_invalidated_lists() {
let s = VaSliceParameterBufferH264::zeroed();
assert!(s
.ref_pic_list0
.iter()
.all(|e| e.flags == VA_PICTURE_H264_INVALID));
assert!(s
.ref_pic_list1
.iter()
.all(|e| e.picture_id == VA_INVALID_SURFACE));
assert_eq!(s.slice_data_flag, VA_SLICE_DATA_FLAG_ALL);
}
}