feat(client): M6's rung is wired — libva, dlopen'd, no libavcodec

The native VAAPI decoder now runs end to end: pf-vaadec's plans go into
libva's buffers, the surface comes back as DRM-PRIME dmabufs, and the
presenter imports them exactly as it does the FFmpeg rung's. Pin-only —
`PUNKTFUNK_DECODER=native-vaapi` — for the reason M5's D3D11VA rung was:
`auto` admission is earned with hardware parity and a soak, and this rung
has decoded nothing yet.

libva is dlopen'd rather than linked, so the pf-lxcheck2 container compiles
and clippies the whole thing without libva-dev, and a machine without a
VAAPI runtime gets a clean refusal instead of a packaging dependency.

The surface pool is not the slot map. `SlotMap::assign` hands out the lowest
free slot, and a slot freed by an access unit's own removals is free by the
time that unit's picture takes it — measured at 225 of the vendored vector's
250 access units. A surface bound by slot index would therefore decode, on
nine frames in ten, into the surface still holding the picture on screen. So
`plan_to_va` now takes the decode target as a parameter, bound by the caller
at activation time the way pf-vkdecode binds a pool image, and a surface is
free only when no live picture is bound to it, no output is owed for it, and
no consumer holds it.

Measured rather than transcribed, as everywhere else here: layout-probe.c
grew the export descriptor (312 bytes, objects[4]/layers[4]), the buffer-type
enumerators — VASliceParameterBufferType is 4 and VASliceDataBufferType is 5,
not the 3 and 4 that counting off the header suggests — and the config,
attribute and generic-value layouts. All pinned as compile-time assertions,
which is how the 12-byte VAGenericValue in the first draft was caught: the C
union holds a pointer, so it is 8-aligned and 16 bytes.

The plane walk lives in pf-vaadec, pure and unit-tested on macOS, because it
is the one structure the DRIVER writes and we read: SEPARATE_LAYERS returns
NV12 as two layers, and taking layers[0] is the green screen this project has
already paid for. It also refuses what it cannot express rather than guessing
— a bogus object count, a plane naming an object that is not there, objects
disagreeing on tiling.

Own DecodedImage variant, same payload type. The physical hand-off is
identical to the FFmpeg rung's, so the presenter keeps ONE arm and one
demotion streak; the variant exists so the compiler asks which rung decoded
wherever that matters. Both D3D11VA rungs share a variant and `1573a987` had
to fix the consequence afterwards — a "native" soak that could silently have
been an FFmpeg soak. Here the four uncovered matches were compile errors.

Buffers are destroyed by us, not by vaEndPicture: va.h is explicit that the
user must call vaDestroyBuffer, and the libva 0.x behaviour is long gone.
Leaking two per picture at 60 fps exhausts the driver's store in minutes.

pf-vaadec's presenter headroom was 4, written against no consumer. The Vulkan
rung had already measured the client pipeline at four to seven held frames;
it is 8 now, pinned to that crate's constant so a re-measurement moves both.

Gates: macOS fmt/clippy/341 tests/cargo doc, and in the container clippy
-D warnings over six crates, 795 tests, workspace check.

Hardware legs are still owed — no AMD/Mesa or Intel box was reachable.
This commit is contained in:
2026-08-06 16:56:38 +02:00
parent 61b96c3837
commit a6e51215fd
17 changed files with 2780 additions and 57 deletions
Generated
+2
View File
@@ -2990,11 +2990,13 @@ dependencies = [
"ash",
"async-channel",
"ffmpeg-next",
"libloading",
"mdns-sd",
"opus",
"pf-dxvadec",
"pf-ffvk",
"pf-update-check",
"pf-vaadec",
"pf-vkdecode",
"pipewire",
"punktfunk-core",
+3 -1
View File
@@ -62,7 +62,9 @@ tone-map in-shader to SDR when it doesn't (`PUNKTFUNK_TONEMAP_PEAK` tunes the ro
default ≈1000 nits). The host still gates the upgrade behind its `PUNKTFUNK_10BIT`
policy.
Debug/bisect knobs: `PUNKTFUNK_DECODER=native-vulkan|vulkan|vaapi|d3d11va|software`, `PUNKTFUNK_PRESENT_MODE=
Debug/bisect knobs: `PUNKTFUNK_DECODER=native-vulkan|native-vaapi|native-d3d11va|vulkan|vaapi|d3d11va|software`
(the three `native-*` values pin this program's own decoders; `native-vaapi` also takes
`PUNKTFUNK_VAAPI_DEVICE=/dev/dri/renderDNNN` to choose the GPU), `PUNKTFUNK_PRESENT_MODE=
mailbox|fifo|immediate|fifo_relaxed` (default MAILBOX, FIFO where the surface offers no
MAILBOX — AMD on Windows), `PUNKTFUNK_VK_DEVICE=<index>` (multi-GPU), and
`PUNKTFUNK_HW_FAULT=import` (fault every VAAPI dmabuf import — proves the three-strike
+10
View File
@@ -59,6 +59,16 @@ rand = "0.9"
[target.'cfg(target_os = "linux")'.dependencies]
pipewire = "0.9"
sdl3 = { version = "0.18", features = ["hidapi"] }
# Native VAAPI decode (M6 of the native-decode program): the hand-declared libva buffer
# layouts, the profile/format/surface decisions, the AuPlan → picparams/IQ/slice
# conversion and the DRM-PRIME export descriptor that `video_vaapi_native` marshals.
# Cross-platform on purpose — everything decidable without a device is tested by the
# ordinary macOS and container gates, exactly as pf-dxvadec does for Windows.
pf-vaadec = { path = "../pf-vaadec" }
# libva itself is dlopen'd, never linked (see `video_vaapi_native`'s module docs): the
# container can then compile and clippy the whole rung without `libva-dev`, and a machine
# without a VAAPI runtime gets a clean refusal instead of a packaging dependency.
libloading = "0.8"
[target.'cfg(windows)'.dependencies]
wasapi = "0.23"
+6
View File
@@ -77,6 +77,12 @@ mod video_software;
mod video_libav;
#[cfg(target_os = "linux")]
mod video_vaapi;
// Native VAAPI decode (M6 of the native-decode program): pf-vaadec's plans driven
// straight into libva, dlopen'd at runtime, exporting DRM-PRIME dmabufs the
// presenter imports — the FFmpeg-free replacement for `video_vaapi`. Pin-only for
// now (`PUNKTFUNK_DECODER=native-vaapi`).
#[cfg(target_os = "linux")]
pub mod video_vaapi_native;
// Native Vulkan Video decode (WP-C of the native-decode program, HEVC added by M3
// WP-2): pf-vkdecode's H.264/H.265 decoders on the presenter's shared device — auto's
// rung immediately above FFmpeg-Vulkan (2026-08-05 ladder decision; the program is
+9
View File
@@ -892,6 +892,11 @@ fn pump(
DecodedImage::Cpu(_) => "software",
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(_) => "vaapi",
// A separate VARIANT rather than a flag on the frame,
// unlike the D3D11VA pair below — so this tag cannot be
// forgotten, only written.
#[cfg(target_os = "linux")]
DecodedImage::NativeDmabuf(_) => "native-vaapi",
DecodedImage::VkFrame(_) => "vulkan",
// Both D3D11VA rungs deliver this variant — they share the
// hand-off ring on purpose — so the frame carries which one
@@ -915,6 +920,10 @@ fn pump(
DecodedImage::Cpu(c) => (c.width, c.height, "software"),
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(d) => (d.width, d.height, "vaapi-dmabuf"),
#[cfg(target_os = "linux")]
DecodedImage::NativeDmabuf(d) => {
(d.width, d.height, "native-vaapi-dmabuf")
}
DecodedImage::VkFrame(v) => (v.width, v.height, "vulkan-video"),
#[cfg(windows)]
DecodedImage::D3d11(d) => (d.width, d.height, "d3d11va"),
+140 -16
View File
@@ -12,11 +12,12 @@
//! native → vulkan → software on Intel/unknown. Windows: native → vulkan → d3d11va →
//! software on NVIDIA/AMD, d3d11va → native → vulkan → software on Intel/unknown.
//! Override:
//! `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software|native-vulkan|native-d3d11va` —
//! `vulkan` names the FFmpeg-Vulkan backend specifically; `native-vulkan` pins the
//! `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software|native-vulkan|native-d3d11va|native-vaapi`
//! `vulkan` names the FFmpeg-Vulkan backend specifically; `native-vulkan` pins the
//! pf-vkdecode decoder by name, skipping the vendor-ordered rungs ahead of it;
//! `native-d3d11va` (Windows) pins M5's pf-dxvadec `ID3D11VideoDecoder` rung, which is
//! reachable ONLY by that pin — it is absent from every `auto` arm until it has the
//! `native-d3d11va` (Windows) pins M5's pf-dxvadec `ID3D11VideoDecoder` rung and
//! `native-vaapi` (Linux) pins M6's pf-vaadec libva rung. Both of those are reachable
//! ONLY by their pin — they are absent from every `auto` arm until they have the
//! hardware evidence M2's native rung had before IT joined `auto`):
//!
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
@@ -81,6 +82,20 @@ pub enum DecodedImage {
Cpu(CpuFrame),
#[cfg(target_os = "linux")]
Dmabuf(DmabufFrame),
/// The NATIVE VAAPI rung's output (`pf-vaadec` + `video_vaapi_native`, M6) —
/// physically the same thing as [`DecodedImage::Dmabuf`], and deliberately the
/// same payload type, because the import a consumer performs is identical:
/// dmabuf fds plus a plane layout. It is a separate VARIANT purely so the two
/// rungs can never be confused for one another.
///
/// That is not fastidiousness. Both D3D11VA rungs share one variant (they share
/// the hand-off ring on purpose), and the consequence had to be fixed in
/// `1573a987`: the `stats:` decode-path tag is derived from the variant, so a
/// "native" soak could silently have been an FFmpeg soak, and there was no way
/// to tell from the log. Here the compiler asks the question instead — every
/// `match` on `DecodedImage` must say which rung it means.
#[cfg(target_os = "linux")]
NativeDmabuf(DmabufFrame),
/// FFmpeg Vulkan Video output: a VkImage already on the PRESENTER's device.
VkFrame(VkVideoFrame),
/// D3D11VA output copied into a shareable NT-handle texture the presenter imports
@@ -485,7 +500,7 @@ impl DecodedImage {
match self {
DecodedImage::Cpu(f) => f.keyframe,
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(f) => f.keyframe,
DecodedImage::Dmabuf(f) | DecodedImage::NativeDmabuf(f) => f.keyframe,
DecodedImage::VkFrame(f) => f.keyframe,
#[cfg(windows)]
DecodedImage::D3d11(f) => f.keyframe,
@@ -528,7 +543,7 @@ impl DecodedImage {
match self {
DecodedImage::Cpu(f) => (f.width, f.height),
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(f) => (f.width, f.height),
DecodedImage::Dmabuf(f) | DecodedImage::NativeDmabuf(f) => (f.width, f.height),
DecodedImage::VkFrame(f) => (f.width, f.height),
#[cfg(windows)]
DecodedImage::D3d11(f) => (f.width, f.height),
@@ -581,21 +596,45 @@ pub struct DmabufPlane {
pub stride: u32,
}
/// Owns the mapped DRM-PRIME `AVFrame` (which in turn references the VAAPI surface).
/// Dropping it releases the surface back to the decoder pool and closes the fds.
pub struct DrmFrameGuard(pub(crate) *mut ffmpeg::ffi::AVFrame);
// SAFETY: the guard owns one `AVFrame` and frees it exactly once in `Drop`. libav's buffer
/// Keeps a decoded surface alive until the consumer's GPU reads are done: dropping
/// it releases the surface back to its decoder's pool and closes the fds.
///
/// The consumer treats this as opaque — the presenter dups every dmabuf fd it
/// imports and simply holds the guard until its fence has been waited — so the only
/// thing the two variants differ in is WHO owns the surface. libavcodec's rungs hand
/// over a mapped `AVFrame`; the native VAAPI rung (`video_vaapi_native`, M6) owns a
/// `VASurface` from its own pool and has no `AVFrame` at all, which is precisely the
/// seam that had to be widened for it to exist. M10 deletes the FFmpeg variant and
/// this enum collapses again.
pub enum DrmFrameGuard {
/// A mapped DRM-PRIME `AVFrame` — the FFmpeg VAAPI hwaccel — or the cloned
/// `AVFrame` behind an `AVVkFrame` on the FFmpeg Vulkan path.
Av(*mut ffmpeg::ffi::AVFrame),
/// The native VAAPI rung's own owner: closes the exported PRIME fds and returns
/// the surface to the decoder's pool.
#[cfg(target_os = "linux")]
NativeVa(crate::video_vaapi_native::VaFrameGuard),
}
// SAFETY: the `Av` variant owns one `AVFrame` and frees it exactly once in `Drop`. libav's buffer
// refcounts are atomic and its hwframe pool is internally locked, so releasing the frame — and with
// it the VAAPI surface, back to the decoder's pool — from a different thread than the one that
// mapped it is sound. That is the whole point here: the guard is handed to GTK and dropped on the
// main thread while the pump thread keeps decoding. Moved, never shared; deliberately NOT `Sync`.
// The `NativeVa` variant is `Send` on its own (owned fds plus an `mpsc::Sender`) and needs no
// promise from here.
unsafe impl Send for DrmFrameGuard {}
impl Drop for DrmFrameGuard {
fn drop(&mut self) {
// SAFETY: `self.0` is the one `AVFrame` this guard owns; `av_frame_free` releases it
// exactly once (this `Drop` runs once) and nulls the pointer through the `&mut`.
unsafe { ffmpeg::ffi::av_frame_free(&mut self.0) };
match self {
// SAFETY: this is the one `AVFrame` the guard owns; `av_frame_free` releases it
// exactly once (this `Drop` runs once) and nulls the pointer through the `&mut`.
DrmFrameGuard::Av(frame) => unsafe { ffmpeg::ffi::av_frame_free(frame) },
// The native guard releases through its own `Drop`, which runs as this value's
// fields are dropped — right after this match.
#[cfg(target_os = "linux")]
DrmFrameGuard::NativeVa(_) => {}
}
}
}
@@ -613,6 +652,18 @@ enum Backend {
NativeVulkan(Box<NativeVulkanDecoder>),
#[cfg(target_os = "linux")]
Vaapi(VaapiDecoder),
/// Native VAAPI (`pf-vaadec` + `video_vaapi_native`) — M6's replacement for the
/// FFmpeg-backed [`Backend::Vaapi`] rung: libva driven straight from pf-bitstream
/// plans, dlopen'd, exporting the same DRM-PRIME dmabufs, no libavcodec.
/// **Pin-only** (`PUNKTFUNK_DECODER=native-vaapi`) and deliberately NOT in the
/// automatic ladder, on the same rule M5's native D3D11VA rung follows: `auto`
/// admission is earned with hardware parity and a soak, and this rung has decoded
/// nothing yet. Errors ride the SAME streak/demotion machinery as every other
/// hardware rung.
/// Boxed: the decoder (two planners, a display and a surface pool) dwarfs the
/// other variants.
#[cfg(target_os = "linux")]
NativeVaapi(Box<crate::video_vaapi_native::NativeVaapiDecoder>),
#[cfg(windows)]
D3d11va(crate::video_d3d11::D3d11vaDecoder),
/// Native D3D11VA (`pf-dxvadec` + `video_d3d11_native`) — M5's replacement for the
@@ -791,6 +842,20 @@ fn native_d3d11_codec(codec_id: ffmpeg::codec::Id) -> Option<pf_dxvadec::Codec>
}
}
/// The native VAAPI decoder for a negotiated wire codec, or `None` for one pf-vaadec
/// cannot decode. Like its DXVA twin there is no caps bit to consult first: VAAPI
/// advertises support as a profile/entrypoint pair on the DISPLAY, which
/// [`crate::video_vaapi_native::NativeVaapiDecoder::new`] queries on the device it is
/// about to build on.
#[cfg(target_os = "linux")]
fn native_vaapi_codec(codec_id: ffmpeg::codec::Id) -> Option<pf_vaadec::Codec> {
match codec_id {
ffmpeg::codec::Id::H264 => Some(pf_vaadec::Codec::H264),
ffmpeg::codec::Id::HEVC => Some(pf_vaadec::Codec::H265),
_ => None,
}
}
/// The native Vulkan Video admission gate (WP-C of the native-decode program, widened
/// by the 2026-08-05 ladder decision and again by M3 WP-2's HEVC wiring): the
/// pf-vkdecode backend engages when `choice` asks for it — by name
@@ -1127,6 +1192,37 @@ impl Decoder {
}
choice = "auto".to_string();
}
// Native VAAPI (M6, pf-vaadec) — PIN ONLY, ahead of everything because a pin is a
// pin, and absent from every `auto` arm below for the same reason its D3D11VA
// sibling is: `auto` admission is earned with hardware parity and a soak. A
// refusal or an init failure logs and drops to the standard ladder (choice reads
// as `auto` from here on), so a native failure is never quieter, nor lands
// somewhere other, than an FFmpeg rung's failure.
#[cfg(target_os = "linux")]
if choice == crate::video_vaapi_native::DECODER_PIN {
match native_vaapi_codec(codec_id) {
Some(codec) => {
match crate::video_vaapi_native::NativeVaapiDecoder::new(codec, stream) {
Ok(d) => {
tracing::info!(
?codec_id,
decoder = d.name(),
"native VAAPI hardware decode active (pf-vaadec, zero-copy dmabuf)"
);
return done(Backend::NativeVaapi(Box::new(d)));
}
Err(e) => tracing::warn!(reason = %format!("{e:#}"),
"native VAAPI init failed — demoting to the standard ladder"),
}
}
None => tracing::warn!(
?codec_id,
"PUNKTFUNK_DECODER=native-vaapi refused (needs an H.264 or HEVC \
session) standard ladder"
),
}
choice = "auto".to_string();
}
let mut native_tried = false;
if choice == "native-vulkan" {
if native_vulkan_gate(
@@ -1398,6 +1494,10 @@ impl Decoder {
// claiming a driver verdict nothing can produce.
#[cfg(windows)]
Backend::NativeD3d11va(d) => Some(d.health()),
// Same shape as the DXVA rung above, for the same reason: libva has no
// per-picture decode-status query either.
#[cfg(target_os = "linux")]
Backend::NativeVaapi(d) => Some(d.health()),
_ => None,
}
}
@@ -1547,6 +1647,20 @@ impl Decoder {
}
#[cfg(target_os = "linux")]
Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)),
#[cfg(target_os = "linux")]
Backend::NativeVaapi(v) => {
debug_assert!(complete, "partial AUs are pyrowave-only");
let r = v.decode(au).map(|f| f.map(DecodedImage::NativeDmabuf));
// Same split as the two native rungs above, for the same reason: this
// rung can SEE stream damage, and turning what an FFmpeg rung conceals
// silently into an error would demote it on exactly the lossy links it
// exists to diagnose.
if v.take_recovery_request() {
self.want_keyframe = true;
concealed = true;
}
r
}
#[cfg(windows)]
Backend::D3d11va(d) => d.decode(au).map(|f| f.map(DecodedImage::D3d11)),
#[cfg(windows)]
@@ -1595,6 +1709,8 @@ impl Decoder {
Backend::D3d11va(_) => "D3D11VA",
#[cfg(windows)]
Backend::NativeD3d11va(_) => "native D3D11VA",
#[cfg(target_os = "linux")]
Backend::NativeVaapi(_) => "native VAAPI",
_ => "VAAPI",
};
self.vaapi_fails += 1;
@@ -1644,13 +1760,21 @@ impl Decoder {
// FFmpeg-Vulkan-on-Mesa error-streaking where VAAPI streams
// perfectly); only when that can't be built either does the
// session land on software.
// The NATIVE VAAPI rung demotes here too, and to the same place: its
// failure is a statement about pf-vaadec's submission, not about
// VAAPI, so libavcodec's decoder on the very same profile is the
// right next rung — and while that rung is pin-only, this is the
// only way a lab session that pinned it keeps hardware decode.
#[cfg(target_os = "linux")]
if matches!(self.backend, Backend::Vulkan(_) | Backend::NativeVulkan(_)) {
if matches!(
self.backend,
Backend::Vulkan(_) | Backend::NativeVulkan(_) | Backend::NativeVaapi(_)
) {
match VaapiDecoder::new(self.codec_id) {
Ok(v) => {
tracing::warn!(error = %e, fails = self.vaapi_fails,
decoder = v.name(),
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
from = which, decoder = v.name(),
"hardware decode failing repeatedly — demoting to VAAPI");
self.backend = Backend::Vaapi(v);
self.vaapi_fails = 0;
self.first_fail = None;
+1 -1
View File
@@ -197,7 +197,7 @@ impl VaapiDecoder {
return Err(averr("av_hwframe_map", r));
}
let desc = (*drm).data[0] as *const ffi::AVDRMFrameDescriptor;
let guard = DrmFrameGuard(drm);
let guard = DrmFrameGuard::Av(drm);
let d = &*desc;
if d.nb_layers < 1 || d.nb_objects < 1 {
bail!("DRM descriptor without layers/objects");
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -424,7 +424,7 @@ impl VulkanDecoder {
coded_height: ((*fc).height.max((*self.frame).height)) as u32,
color: ColorDesc::from_raw(self.frame),
keyframe: frame_is_keyframe(self.frame),
guard: DrmFrameGuard(clone),
guard: DrmFrameGuard::Av(clone),
})
}
}
+8 -2
View File
@@ -1635,8 +1635,14 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
st.hdr_untonemapped = true;
presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())?
}
// Both VAAPI rungs — libavcodec's and M6's native one — hand over
// the same thing: dmabuf fds plus a plane layout, with the guard
// opaque either way. One arm, therefore, rather than a second copy
// of the import and its failure-streak demotion. They stay separate
// VARIANTS so that everywhere it MATTERS which rung decoded (the
// stats tag) the compiler asks; here it genuinely does not.
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(d)
DecodedImage::Dmabuf(d) | DecodedImage::NativeDmabuf(d)
if presenter.supports_dmabuf() && !st.dmabuf_demoted =>
{
st.hdr = d.color.is_pq();
@@ -1673,7 +1679,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
}
}
#[cfg(target_os = "linux")]
DecodedImage::Dmabuf(_) => {
DecodedImage::Dmabuf(_) | DecodedImage::NativeDmabuf(_) => {
// No import extensions on this device (or already demoted) — the
// pump rebuilds the decoder as software; frames flow again soon.
if !st.dmabuf_demoted {
+63
View File
@@ -24,6 +24,7 @@
#include <stddef.h>
#include <va/va.h>
#include <va/va_dec_hevc.h>
#include <va/va_drmcommon.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))
@@ -221,5 +222,67 @@ int main(void) {
}
printf("VA_PADDING_LOW=%d VA_PADDING_MEDIUM=%d\n", VA_PADDING_LOW, VA_PADDING_MEDIUM);
/*
* The export descriptor. This one is not a buffer we FILL it is a struct the
* driver WRITES, so a wrong layout is read as plausible garbage (an fd from the
* middle of a pitch, a plane count from a modifier's high word) rather than
* refused. It carries fixed-size arrays whose bounds the flattening walk trusts,
* which is exactly the shape that turned into the green-screen bug once already.
*/
S(VADRMPRIMESurfaceDescriptor);
O(VADRMPRIMESurfaceDescriptor, fourcc);
O(VADRMPRIMESurfaceDescriptor, width);
O(VADRMPRIMESurfaceDescriptor, height);
O(VADRMPRIMESurfaceDescriptor, num_objects);
O(VADRMPRIMESurfaceDescriptor, objects);
O(VADRMPRIMESurfaceDescriptor, num_layers);
O(VADRMPRIMESurfaceDescriptor, layers);
printf("count VADRMPRIMESurfaceDescriptor objects %zu\n",
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->objects) /
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->objects[0]));
printf("count VADRMPRIMESurfaceDescriptor layers %zu\n",
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->layers) /
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->layers[0]));
printf("count layer.object_index %zu\n",
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->layers[0].object_index) /
sizeof(((VADRMPRIMESurfaceDescriptor *)0)->layers[0].object_index[0]));
/*
* The enumerators the runtime calls pass by value. Printed rather than
* transcribed because two of them are the exact pair this program has already
* been warned about: VASliceParameterBufferType and VASliceDataBufferType are
* 4 and 5, not the 3 and 4 that counting the enum from the top suggests.
*/
printf("enum VAEntrypointVLD %d\n", VAEntrypointVLD);
printf("enum VAConfigAttribRTFormat %d\n", VAConfigAttribRTFormat);
printf("enum VAPictureParameterBufferType %d\n", VAPictureParameterBufferType);
printf("enum VAIQMatrixBufferType %d\n", VAIQMatrixBufferType);
printf("enum VASliceParameterBufferType %d\n", VASliceParameterBufferType);
printf("enum VASliceDataBufferType %d\n", VASliceDataBufferType);
printf("enum VA_EXPORT_SURFACE_READ_ONLY 0x%04x\n", VA_EXPORT_SURFACE_READ_ONLY);
printf("enum VA_EXPORT_SURFACE_SEPARATE_LAYERS 0x%04x\n",
VA_EXPORT_SURFACE_SEPARATE_LAYERS);
printf("enum VA_SURFACE_ATTRIB_SETTABLE 0x%04x\n", VA_SURFACE_ATTRIB_SETTABLE);
printf("enum VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2 0x%08x\n",
VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2);
printf("enum VASurfaceAttribPixelFormat %d\n", VASurfaceAttribPixelFormat);
printf("enum VAGenericValueTypeInteger %d\n", VAGenericValueTypeInteger);
printf("enum VA_STATUS_SUCCESS %d\n", VA_STATUS_SUCCESS);
printf("enum VA_INVALID_ID 0x%08x\n", VA_INVALID_ID);
printf("enum VA_FOURCC_NV12 0x%08x\n", VA_FOURCC_NV12);
printf("enum VA_FOURCC_P010 0x%08x\n", VA_FOURCC_P010);
printf("enum VA_PROGRESSIVE 0x%04x\n", VA_PROGRESSIVE);
S(VASurfaceAttrib);
O(VASurfaceAttrib, type);
O(VASurfaceAttrib, flags);
O(VASurfaceAttrib, value);
S(VAGenericValue);
O(VAGenericValue, type);
O(VAGenericValue, value);
S(VAConfigAttrib);
O(VAConfigAttrib, type);
O(VAConfigAttrib, value);
return 0;
}
+32 -6
View File
@@ -112,11 +112,25 @@ pub fn rt_format(chroma_format_idc: u8, depth: u8) -> Result<u32, ConfigError> {
}
}
/// Headroom over the DPB for pictures the presenter still holds.
/// Headroom over the DPB for pictures the CONSUMER 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;
/// A surface handed to the presenter is not free to decode into — that is what
/// zero-copy costs — and a pool sized exactly to the DPB stalls the decoder behind
/// the display.
///
/// **8, matching `pf_vkdecode::images::HOLD_HEADROOM`, and for its measurement**:
/// the real client pipeline holds roughly four to seven frames at steady state (two
/// bounded(2) channels, the frame store's 1..=3 preroll, the in-flight present and
/// the retired-frame slot), so eight leaves a frame of slack and a consumer holding
/// more than that has earned an honest "pool exhausted" rather than a silent stall.
/// The number was 4 when this module was written against no consumer; the native
/// Vulkan rung had already measured the pipeline by then, and 4 would have run the
/// pool dry on an ordinary stream.
///
/// (The FFmpeg VAAPI rung asks libavcodec for `extra_hw_frames = 4` and survives on
/// it, but its pool is not this pool: `av_hwframe_get_buffer` BLOCKS until a surface
/// frees, so its headroom buys latency where ours buys correctness.)
pub const PRESENTER_HEADROOM: usize = 8;
/// How many decode surfaces a session allocates: the DPB, plus the picture being
/// decoded, plus [`PRESENTER_HEADROOM`].
@@ -168,7 +182,19 @@ mod tests {
#[test]
fn the_surface_pool_covers_dpb_plus_current_plus_headroom() {
assert_eq!(surface_count(4), 9);
assert_eq!(surface_count(16), 21);
assert_eq!(surface_count(4), 4 + 1 + PRESENTER_HEADROOM);
assert_eq!(surface_count(16), 16 + 1 + PRESENTER_HEADROOM);
}
/// The headroom must cover what the client pipeline actually holds, which the
/// native Vulkan rung measured before this crate existed. Pinning it to that
/// crate's constant means a future re-measurement moves both rungs together
/// instead of leaving this one quietly short.
#[test]
fn the_headroom_matches_the_pipeline_depth_the_vulkan_rung_measured() {
assert_eq!(
PRESENTER_HEADROOM,
pf_vkdecode::images::HOLD_HEADROOM as usize
);
}
}
+466
View File
@@ -0,0 +1,466 @@
//! The export descriptor — `vaExportSurfaceHandle`'s answer — and the walk that
//! turns it into the plane list a dmabuf import consumes.
//!
//! This is the one structure in the rung that the DRIVER writes and we read. Every
//! other buffer here is one we fill, where a wrong field is at worst refused; a
//! misread descriptor is plausible garbage — an fd taken from the middle of a
//! pitch, a plane count read out of a modifier's high word — and it imports
//! successfully into a texture of nonsense. So the layout is measured by
//! `layout-probe.c` like everything else, and the walk lives here, pure, where
//! macOS and the container run its tests.
//!
//! # The bug this walk exists to not repeat
//!
//! With `VA_EXPORT_SURFACE_SEPARATE_LAYERS` an NV12 surface comes back as **two
//! layers** — an `R8` luma layer and a `GR88` chroma layer, one plane each — not as
//! one two-plane `NV12` layer. Taking `layers[0]` and calling it the surface is how
//! this project once painted the screen green: the importer saw a single-plane R8
//! texture and the chroma was simply gone. Hence [`flatten`]: every plane of every
//! layer, in declared order, and the surface's format comes from the descriptor's
//! own top-level `fourcc` rather than from any layer's component format.
//!
//! (The alternative, `VA_EXPORT_SURFACE_COMPOSED_LAYERS`, asks the driver for one
//! layer describing the whole surface. It is not universally implemented, and the
//! separate-layers form is what the FFmpeg VAAPI path this rung replaces has always
//! used — so it is the form the fleet's drivers are exercised on.)
use std::os::raw::c_int;
/// `VA_EXPORT_SURFACE_READ_ONLY` — the decoder keeps writing this surface's future
/// siblings; the consumer only samples.
pub const VA_EXPORT_SURFACE_READ_ONLY: u32 = 0x0001;
/// `VA_EXPORT_SURFACE_SEPARATE_LAYERS` — one layer per plane (module docs).
pub const VA_EXPORT_SURFACE_SEPARATE_LAYERS: u32 = 0x0004;
/// `VA_FOURCC_NV12` — identical to `DRM_FORMAT_NV12`; the two namespaces agree on
/// the packed-fourcc value, which is why the descriptor's `fourcc` can be handed
/// to a DRM importer unchanged.
pub const VA_FOURCC_NV12: u32 = 0x3231_564e;
/// `VA_FOURCC_P010` — identical to `DRM_FORMAT_P010`.
pub const VA_FOURCC_P010: u32 = 0x3031_3050;
/// Fixed array bounds in the descriptor, measured (`layout-probe.c`).
pub const MAX_OBJECTS: usize = 4;
pub const MAX_LAYERS: usize = 4;
pub const MAX_PLANES_PER_LAYER: usize = 4;
/// One buffer object backing the surface: an fd we OWN and must close.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct VaDrmPrimeObject {
/// DRM PRIME fd. `c_int` because that is what the header says; the caller
/// wraps it in an `OwnedFd` the moment the export succeeds.
pub fd: c_int,
pub size: u32,
pub drm_format_modifier: u64,
}
/// One layer: under `SEPARATE_LAYERS` this is a single plane with its own
/// component format.
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct VaDrmPrimeLayer {
/// The LAYER's DRM format (`R8`, `GR88`, …) — a component format, never the
/// surface's. See the module docs.
pub drm_format: u32,
pub num_planes: u32,
pub object_index: [u32; MAX_PLANES_PER_LAYER],
pub offset: [u32; MAX_PLANES_PER_LAYER],
pub pitch: [u32; MAX_PLANES_PER_LAYER],
}
/// `VADRMPRIMESurfaceDescriptor` (`va_drmcommon.h`).
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct VaDrmPrimeSurfaceDescriptor {
/// The SURFACE's fourcc (`VA_FOURCC_NV12`, `VA_FOURCC_P010`, …) — the combined
/// format, and the one a DRM importer wants.
pub fourcc: u32,
pub width: u32,
pub height: u32,
pub num_objects: u32,
pub objects: [VaDrmPrimeObject; MAX_OBJECTS],
pub num_layers: u32,
pub layers: [VaDrmPrimeLayer; MAX_LAYERS],
}
impl VaDrmPrimeSurfaceDescriptor {
/// A zeroed descriptor for the driver to fill.
///
/// Zero is not a valid `num_objects`/`num_layers`, so a driver that returns
/// success without writing anything is caught by [`flatten`] rather than read
/// as a surface with no planes.
pub fn zeroed() -> Self {
Self {
fourcc: 0,
width: 0,
height: 0,
num_objects: 0,
objects: [VaDrmPrimeObject {
fd: -1,
size: 0,
drm_format_modifier: 0,
}; MAX_OBJECTS],
num_layers: 0,
layers: [VaDrmPrimeLayer {
drm_format: 0,
num_planes: 0,
object_index: [0; MAX_PLANES_PER_LAYER],
offset: [0; MAX_PLANES_PER_LAYER],
pitch: [0; MAX_PLANES_PER_LAYER],
}; MAX_LAYERS],
}
}
}
/// One plane of the flattened surface. `fd` is BORROWED from the descriptor's
/// object list — several planes routinely name the same object — so the caller
/// owns the objects and the planes reference them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportedPlane {
pub fd: c_int,
pub offset: u32,
pub stride: u32,
}
/// The flattened surface: what an importer needs, in plane order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportedSurface {
/// The combined DRM fourcc, from the descriptor's own top-level field.
pub fourcc: u32,
pub width: u32,
pub height: u32,
/// The tiling modifier. Every object must agree on it — see [`flatten`].
pub modifier: u64,
/// Every plane of every layer, in declared order.
pub planes: Vec<ExportedPlane>,
/// The fds the caller OWNS and must close, one per object.
pub object_fds: Vec<c_int>,
}
/// Why a descriptor cannot be read as a surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportError {
/// Zero (a driver that "succeeded" without writing) or more than the arrays hold.
ObjectCount(u32),
LayerCount(u32),
PlaneCount {
layer: usize,
planes: u32,
},
/// A plane named an object outside `num_objects` — reading it would take an fd
/// from uninitialised descriptor memory.
ObjectIndex {
layer: usize,
plane: usize,
index: u32,
},
/// An object came back without a usable fd.
BadFd {
object: usize,
fd: c_int,
},
/// The objects disagree on the tiling modifier. A dmabuf import takes ONE
/// modifier for the whole image, so importing plane 1 under plane 0's tiling
/// would decode the chroma as if it were laid out some other way. Every fleet
/// driver puts the whole surface in one BO; a driver that does not needs code
/// that does not exist yet, and must say so rather than guess.
MixedModifiers {
first: u64,
other: u64,
},
}
impl std::fmt::Display for ExportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ExportError::ObjectCount(n) => {
write!(
f,
"descriptor declares {n} objects (want 1..={MAX_OBJECTS})"
)
}
ExportError::LayerCount(n) => {
write!(f, "descriptor declares {n} layers (want 1..={MAX_LAYERS})")
}
ExportError::PlaneCount { layer, planes } => write!(
f,
"layer {layer} declares {planes} planes (want 1..={MAX_PLANES_PER_LAYER})"
),
ExportError::ObjectIndex {
layer,
plane,
index,
} => write!(
f,
"layer {layer} plane {plane} names object {index}, which the descriptor \
does not have"
),
ExportError::BadFd { object, fd } => {
write!(f, "object {object} exported fd {fd}")
}
ExportError::MixedModifiers { first, other } => write!(
f,
"the surface's objects disagree on tiling ({first:#018x} vs {other:#018x}) — \
a single-modifier import cannot express it"
),
}
}
}
impl std::error::Error for ExportError {}
/// Flatten a descriptor into an importable surface: **every plane of every layer,
/// in declared order** (module docs).
///
/// Validates before it walks, so a malformed descriptor is a typed refusal and
/// never an out-of-bounds read of the fixed arrays. The caller owns
/// [`ExportedSurface::object_fds`] on success; on failure it owns the descriptor's
/// fds and must close them itself — this function takes no ownership either way,
/// because it cannot know whether the export call succeeded.
pub fn flatten(desc: &VaDrmPrimeSurfaceDescriptor) -> Result<ExportedSurface, ExportError> {
let objects = desc.num_objects as usize;
if objects == 0 || objects > MAX_OBJECTS {
return Err(ExportError::ObjectCount(desc.num_objects));
}
let layers = desc.num_layers as usize;
if layers == 0 || layers > MAX_LAYERS {
return Err(ExportError::LayerCount(desc.num_layers));
}
for (i, o) in desc.objects[..objects].iter().enumerate() {
if o.fd < 0 {
return Err(ExportError::BadFd {
object: i,
fd: o.fd,
});
}
}
let modifier = desc.objects[0].drm_format_modifier;
if let Some(o) = desc.objects[1..objects]
.iter()
.find(|o| o.drm_format_modifier != modifier)
{
return Err(ExportError::MixedModifiers {
first: modifier,
other: o.drm_format_modifier,
});
}
let mut planes = Vec::with_capacity(layers * 2);
for (l, layer) in desc.layers[..layers].iter().enumerate() {
let n = layer.num_planes as usize;
if n == 0 || n > MAX_PLANES_PER_LAYER {
return Err(ExportError::PlaneCount {
layer: l,
planes: layer.num_planes,
});
}
for p in 0..n {
let index = layer.object_index[p];
if index as usize >= objects {
return Err(ExportError::ObjectIndex {
layer: l,
plane: p,
index,
});
}
planes.push(ExportedPlane {
fd: desc.objects[index as usize].fd,
offset: layer.offset[p],
stride: layer.pitch[p],
});
}
}
Ok(ExportedSurface {
fourcc: desc.fourcc,
width: desc.width,
height: desc.height,
modifier,
planes,
object_fds: desc.objects[..objects].iter().map(|o| o.fd).collect(),
})
}
// Measured by `layout-probe.c` against libva 2.23.0 headers, not transcribed.
const _: () = {
use std::mem::align_of;
use std::mem::offset_of;
use std::mem::size_of;
assert!(size_of::<VaDrmPrimeObject>() == 16);
assert!(size_of::<VaDrmPrimeLayer>() == 56);
assert!(size_of::<VaDrmPrimeSurfaceDescriptor>() == 312);
assert!(align_of::<VaDrmPrimeSurfaceDescriptor>() == 8);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, fourcc) == 0);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, width) == 4);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, height) == 8);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, num_objects) == 12);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, objects) == 16);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, num_layers) == 80);
assert!(offset_of!(VaDrmPrimeSurfaceDescriptor, layers) == 84);
};
#[cfg(test)]
mod tests {
use super::*;
/// `DRM_FORMAT_R8` / `DRM_FORMAT_GR88` — the component formats a driver reports
/// per layer for NV12 under `SEPARATE_LAYERS`. Present only to build the
/// realistic fixture; nothing in the walk reads them, which is the point.
const DRM_FORMAT_R8: u32 = 0x2038_5220;
const DRM_FORMAT_GR88: u32 = 0x3838_5247;
const MOD: u64 = 0x0200_0000_0180_1002;
/// What radeonsi/iHD actually hand back for an NV12 decode surface: ONE object,
/// TWO layers of one plane each, chroma at a non-zero offset in the same buffer.
fn nv12_two_layers() -> VaDrmPrimeSurfaceDescriptor {
let mut d = VaDrmPrimeSurfaceDescriptor::zeroed();
d.fourcc = VA_FOURCC_NV12;
d.width = 1920;
d.height = 1080;
d.num_objects = 1;
d.objects[0] = VaDrmPrimeObject {
fd: 7,
size: 1920 * 1088 * 3 / 2,
drm_format_modifier: MOD,
};
d.num_layers = 2;
d.layers[0] = VaDrmPrimeLayer {
drm_format: DRM_FORMAT_R8,
num_planes: 1,
object_index: [0; 4],
offset: [0; 4],
pitch: [1920, 0, 0, 0],
};
d.layers[1] = VaDrmPrimeLayer {
drm_format: DRM_FORMAT_GR88,
num_planes: 1,
object_index: [0; 4],
offset: [1920 * 1088, 0, 0, 0],
pitch: [1920, 0, 0, 0],
};
d
}
#[test]
fn both_layers_become_planes_and_the_surface_keeps_its_own_fourcc() {
let out = flatten(&nv12_two_layers()).expect("a well-formed NV12 export");
// The green-screen regression, as an assertion: two planes, not one.
assert_eq!(out.planes.len(), 2, "chroma was dropped");
assert_eq!(
out.fourcc, VA_FOURCC_NV12,
"the surface fourcc must come from the descriptor, not from layers[0].drm_format \
({DRM_FORMAT_R8:#010x})"
);
assert_ne!(out.fourcc, DRM_FORMAT_R8);
assert_eq!(out.planes[0].offset, 0);
assert_eq!(out.planes[1].offset, 1920 * 1088);
// Both planes live in the SAME object, so both carry the same fd — and the
// caller must close it exactly once.
assert_eq!(out.planes[0].fd, 7);
assert_eq!(out.planes[1].fd, 7);
assert_eq!(out.object_fds, vec![7]);
assert_eq!(out.modifier, MOD);
}
#[test]
fn a_multi_plane_layer_flattens_in_declared_order() {
// The COMPOSED-ish shape: one layer that declares both planes itself. The
// walk must handle it without caring which shape the driver chose.
let mut d = VaDrmPrimeSurfaceDescriptor::zeroed();
d.fourcc = VA_FOURCC_P010;
d.num_objects = 2;
d.objects[0] = VaDrmPrimeObject {
fd: 11,
size: 64,
drm_format_modifier: MOD,
};
d.objects[1] = VaDrmPrimeObject {
fd: 12,
size: 32,
drm_format_modifier: MOD,
};
d.num_layers = 1;
d.layers[0] = VaDrmPrimeLayer {
drm_format: VA_FOURCC_P010,
num_planes: 2,
object_index: [0, 1, 0, 0],
offset: [0, 0, 0, 0],
pitch: [3840, 3840, 0, 0],
};
let out = flatten(&d).expect("a well-formed two-object export");
assert_eq!(out.planes.len(), 2);
assert_eq!(out.planes[0].fd, 11);
assert_eq!(out.planes[1].fd, 12);
assert_eq!(out.object_fds, vec![11, 12], "both objects must be closed");
}
#[test]
fn a_driver_that_wrote_nothing_is_refused_rather_than_read() {
let d = VaDrmPrimeSurfaceDescriptor::zeroed();
assert_eq!(flatten(&d), Err(ExportError::ObjectCount(0)));
}
#[test]
fn counts_past_the_arrays_are_refused_before_the_walk() {
let mut d = nv12_two_layers();
d.num_objects = 5;
assert_eq!(flatten(&d), Err(ExportError::ObjectCount(5)));
let mut d = nv12_two_layers();
d.num_layers = 9;
assert_eq!(flatten(&d), Err(ExportError::LayerCount(9)));
let mut d = nv12_two_layers();
d.layers[1].num_planes = 5;
assert_eq!(
flatten(&d),
Err(ExportError::PlaneCount {
layer: 1,
planes: 5
})
);
}
#[test]
fn a_plane_naming_an_object_that_does_not_exist_is_refused() {
// `num_objects` is 1, so object_index 1 addresses descriptor memory the
// driver never wrote — an fd of -1 or worse, a stale one.
let mut d = nv12_two_layers();
d.layers[1].object_index[0] = 1;
assert_eq!(
flatten(&d),
Err(ExportError::ObjectIndex {
layer: 1,
plane: 0,
index: 1
})
);
}
#[test]
fn objects_that_disagree_on_tiling_are_refused_not_averaged() {
let mut d = nv12_two_layers();
d.num_objects = 2;
d.objects[1] = VaDrmPrimeObject {
fd: 8,
size: 16,
drm_format_modifier: 0,
};
d.layers[1].object_index[0] = 1;
assert_eq!(
flatten(&d),
Err(ExportError::MixedModifiers {
first: MOD,
other: 0
})
);
}
#[test]
fn an_object_without_an_fd_is_refused() {
let mut d = nv12_two_layers();
d.objects[0].fd = -1;
assert_eq!(flatten(&d), Err(ExportError::BadFd { object: 0, fd: -1 }));
}
}
+17 -3
View File
@@ -15,9 +15,10 @@
//!
//! # Status
//!
//! **H.264 conversion complete; no libva calls yet.** What remains for the rung is
//! the Linux-only plumbing (config/context/surface creation, `vaRenderPicture`,
//! sync, dmabuf export) and the H.265 twin of [`pic`].
//! **Both codecs converted, and the rung is wired.** `pf-client-core`'s
//! `video_vaapi_native` dlopens libva and drives these buffers; this crate holds
//! everything decidable without a device — including [`drm`], the export
//! descriptor the driver writes back and the plane walk that reads it.
//!
//! Four things this crate settled that a reader would otherwise have to re-derive:
//!
@@ -56,6 +57,7 @@
//! a parameter and stay pure.
pub mod config;
pub mod drm;
pub mod pic;
pub mod pic_h265;
pub mod va;
@@ -69,6 +71,8 @@ pub use pf_vkdecode::SlotMap;
/// 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::ColourDescription;
pub use pf_bitstream::h264::DisplayCrop;
pub use pf_bitstream::h264::H264Planner;
pub use pf_bitstream::h264::PlanError;
pub use pf_bitstream::h264::PlanWarning;
@@ -81,6 +85,16 @@ pub use pf_bitstream::h265::PlanWarning as PlanWarningH265;
pub use pf_vkdecode::is_integrity_warning;
pub use pf_vkdecode::is_integrity_warning_h265;
pub use drm::flatten;
pub use drm::ExportError;
pub use drm::ExportedPlane;
pub use drm::ExportedSurface;
pub use drm::VaDrmPrimeSurfaceDescriptor;
pub use drm::VA_EXPORT_SURFACE_READ_ONLY;
pub use drm::VA_EXPORT_SURFACE_SEPARATE_LAYERS;
pub use drm::VA_FOURCC_NV12;
pub use drm::VA_FOURCC_P010;
pub use config::profile_for;
pub use config::rt_format;
pub use config::surface_count;
+131 -15
View File
@@ -237,8 +237,26 @@ fn surface_of(slots: &SlotMap, surfaces: &[u32], id: PicId) -> Result<(u8, u32),
///
/// `au` is the access unit the plan was built from — needed because the slice data
/// buffer must start at the NAL header byte, and the start-code prefix is three or
/// four bytes depending on the encoder. `surfaces` maps DPB slot to `VASurfaceID`;
/// this crate never allocates one.
/// four bytes depending on the encoder. `surfaces` maps DPB slot to `VASurfaceID`
/// for the pictures the DPB already holds; this crate never allocates one.
///
/// # Why the decode target is a parameter and not `surfaces[setup_slot]`
///
/// The caller binds the target surface, at activation time, exactly as
/// `pf-vkdecode` binds a pool image when a DPB slot is activated. A slot ledger
/// is not a surface allocator: [`SlotMap::assign`] takes the lowest free slot, and
/// a slot freed by this AU's own removals is free by the time the setup picture
/// takes it — measured at **225 of the vendored vector's 250 access units**
/// (`the_setup_picture_routinely_inherits_a_just_freed_slot`). Reading the target
/// out of a slot-indexed table would therefore decode, on nine frames in ten,
/// into the surface holding the picture that was just displayed — which the
/// consumer may still be sampling. Zero-copy means the decoder cannot have that
/// surface back until the consumer says so, and only the caller knows.
///
/// `setup_surface` must be free in that sense: bound to no live picture and held
/// by no consumer. After a successful call the caller binds it to
/// [`DecodePlanVa::setup_slot`], so later access units resolve references to this
/// picture through `surfaces`.
///
/// Nothing mutates `slots` until every fallible step has passed.
pub fn plan_to_va(
@@ -246,6 +264,7 @@ pub fn plan_to_va(
au: &[u8],
slots: &mut SlotMap,
surfaces: &[u32],
setup_surface: u32,
) -> Result<DecodePlanVa, PlanToVaError> {
if plan.slices.is_empty() {
return Err(PlanToVaError::NoSlices);
@@ -271,6 +290,16 @@ pub fn plan_to_va(
capacity: slots.capacity(),
});
}
// The caller binds `setup_surface` to the returned slot, so a table that cannot
// express every slot is caught HERE — before anything mutates the ledger —
// rather than as an out-of-range bind after a successful conversion. Checked
// against the capacity, not the chosen slot, precisely so it stays a pre-check.
if surfaces.len() < slots.capacity() {
return Err(PlanToVaError::SurfaceOutOfRange {
slot: (slots.capacity() - 1) as u8,
surfaces: surfaces.len(),
});
}
// Height is expressed in FRAME macroblocks, so the map-units count doubles for a
// non-frame-only SPS — unreachable inside pf-bitstream's progressive envelope,
@@ -415,16 +444,9 @@ pub fn plan_to_va(
if setup_evicted {
slots.release(setup_id);
}
let curr_surface =
*surfaces
.get(usize::from(setup_slot))
.ok_or(PlanToVaError::SurfaceOutOfRange {
slot: setup_slot,
surfaces: surfaces.len(),
})?;
let curr_pic = VaPictureH264 {
picture_id: curr_surface,
picture_id: setup_surface,
// For the current picture this is `frame_num`, not a long-term index.
frame_idx: u32::from(pic.frame_num),
flags: if pic.is_reference {
@@ -511,6 +533,14 @@ pub fn plan_to_va(
#[cfg(test)]
mod tests {
use super::*;
use crate::va::VA_INVALID_SURFACE;
/// Surface ids for the walks below: `SURFACE_BASE + access-unit index`, so every
/// picture gets its own and none is ever reused. Well away from slot indices, so
/// a mix-up shows as a value rather than a plausible off-by-one — and unique, so
/// a stale or aliased reference cannot hide behind a surface that happens to be
/// right again.
const SURFACE_BASE: u32 = 0x9000;
use crate::va::VA_PICTURE_H264_INVALID;
/// The vendored conformance vector every other rung's parity legs decode: 250
@@ -567,10 +597,13 @@ mod tests {
assert_eq!(aus.len(), 250, "the vendored vector is 250 access units");
let mut planner = H264Planner::new();
// One surface per slot, with ids that are distinguishable from slot indices
// so a mix-up shows up as a value, not as an off-by-one that still looks
// plausible.
let surfaces: Vec<u32> = (0..32u32).map(|i| 0x9000 + i).collect();
// The caller's binding, modelled the way the rung does it: every picture is
// given its OWN never-reused surface id, and the slot table is updated after
// the conversion returns. Ids start well away from slot indices so a mix-up
// shows up as a value rather than as a plausible-looking off-by-one, and
// never reusing one means a stale or aliased reference cannot hide behind a
// surface that happens to be right again.
let mut surfaces: Vec<u32> = Vec::new();
let mut slots: Option<SlotMap> = None;
let mut converted = 0usize;
let mut saw_multi_slice = false;
@@ -581,8 +614,11 @@ mod tests {
.plan_au(au)
.unwrap_or_else(|e| panic!("AU {index}: the clean vector must plan, got {e:?}"));
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
let out = plan_to_va(&plan, au, map, &surfaces)
surfaces.resize(map.capacity(), VA_INVALID_SURFACE);
let setup_surface = SURFACE_BASE + index as u32;
let out = plan_to_va(&plan, au, map, &surfaces, setup_surface)
.unwrap_or_else(|e| panic!("AU {index}: conversion failed: {e}"));
surfaces[usize::from(out.setup_slot)] = setup_surface;
assert_eq!(
out.slices.len(),
@@ -657,6 +693,86 @@ mod tests {
);
}
/// The decode target must never be a surface this same access unit READS.
///
/// This is the question a slot ledger cannot answer, and it is why the caller
/// binds the setup surface instead of the conversion reading one out of a
/// slot-indexed table.
///
/// `SlotMap::assign` takes the LOWEST free slot, and a slot freed by this
/// access unit's own removals is free by the time the setup picture is
/// assigned. Measured on the vendored vector, that is not an edge case: the
/// setup picture inherits a just-freed slot on **225 of 250** access units.
/// A surface bound BY SLOT would therefore decode, on nine frames in ten,
/// into the surface still holding the picture that was just displayed — which
/// under zero-copy the consumer may still be sampling. Hence the pool model
/// this crate's callers use, and hence `setup_surface`.
///
/// The second half of the test is the reassurance that comes with it: given
/// the caller's contract (a surface bound to no live picture), the decode
/// target is never a surface the same access unit READS. That is checked
/// against both readable sets, which are not the same snapshot — `dpb_refs` is
/// taken after this AU's marking process, the per-slice lists before it.
#[test]
fn the_setup_picture_routinely_inherits_a_just_freed_slot() {
use pf_bitstream::h264::H264Planner;
let aus = split_aus(TEST_25FPS_H264);
let mut planner = H264Planner::new();
let mut surfaces: Vec<u32> = Vec::new();
let mut slots: Option<SlotMap> = None;
let mut collisions = 0usize;
let mut first: Option<usize> = None;
let mut inherited = 0usize;
for (index, au) in aus.iter().enumerate() {
let plan = planner.plan_au(au).expect("the clean vector plans");
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
surfaces.resize(map.capacity(), VA_INVALID_SURFACE);
// Which slots this AU's own removals will free — read BEFORE the
// conversion applies them, because afterwards the ledger has forgotten.
let freed: Vec<u8> = plan
.dpb
.removed
.iter()
.filter_map(|id| map.slot_of(*id))
.collect();
let setup_surface = SURFACE_BASE + index as u32;
let out = plan_to_va(&plan, au, map, &surfaces, setup_surface)
.expect("the clean vector converts");
surfaces[usize::from(out.setup_slot)] = setup_surface;
if freed.contains(&out.setup_slot) {
inherited += 1;
}
let curr = out.pic_params.curr_pic.picture_id;
let names =
|e: &VaPictureH264| e.flags & VA_PICTURE_H264_INVALID == 0 && e.picture_id == curr;
let read_by_this_au = out.pic_params.reference_frames.iter().any(names)
|| out.slices.iter().any(|s| {
s.ref_pic_list0.iter().any(names) || s.ref_pic_list1.iter().any(names)
});
if read_by_this_au {
collisions += 1;
first.get_or_insert(index);
}
}
// The measurement this design rests on. A floor rather than the exact
// count, so a planner change that shifts it by a frame does not fail —
// but one that made slot reuse RARE would, and would mean the doc above
// has stopped being true.
assert!(
inherited > 200,
"the setup picture inherited a just-freed slot on only {inherited} of 250 access \
units the reason `setup_surface` is a parameter no longer holds, and the \
documentation that cites it needs re-measuring"
);
assert_eq!(
collisions, 0,
"the decode target collided with a picture this access unit reads, on \
{collisions} of 250 (first at AU {first:?})"
);
}
#[test]
fn start_code_len_reads_both_prefix_forms() {
assert_eq!(start_code_len(&[0, 0, 1, 0x65]), Some(3));
+23 -12
View File
@@ -144,12 +144,15 @@ impl std::fmt::Display for PlanToVaH265Error {
impl std::error::Error for PlanToVaH265Error {}
/// Convert one planned HEVC access unit. See [`crate::pic::plan_to_va`] for the
/// parameter contract — `au` and `surfaces` mean the same things.
/// parameter contract — `au`, `surfaces` and `setup_surface` mean the same things,
/// including the reason the decode target is bound by the caller rather than read
/// out of a slot-indexed table.
pub fn plan_to_va_h265(
plan: &AuPlanH265,
au: &[u8],
slots: &mut SlotMap,
surfaces: &[u32],
setup_surface: u32,
) -> Result<DecodePlanVaH265, PlanToVaH265Error> {
if plan.slices.is_empty() {
return Err(PlanToVaH265Error::NoSlices);
@@ -169,6 +172,14 @@ pub fn plan_to_va_h265(
capacity: slots.capacity(),
});
}
// See the H.264 twin: a pre-check, so the caller's post-call bind of
// `setup_surface` to the returned slot is always in range.
if surfaces.len() < slots.capacity() {
return Err(PlanToVaH265Error::SurfaceOutOfRange {
slot: (slots.capacity() - 1) as u8,
surfaces: surfaces.len(),
});
}
if plan.dpb_refs.len() > REFERENCE_FRAMES_LEN_H265 {
return Err(PlanToVaH265Error::TooManyReferences(plan.dpb_refs.len()));
}
@@ -358,17 +369,9 @@ pub fn plan_to_va_h265(
if setup_evicted {
slots.release(setup_id);
}
let curr_surface =
*surfaces
.get(usize::from(setup_slot))
.ok_or(PlanToVaH265Error::SurfaceOutOfRange {
slot: setup_slot,
surfaces: surfaces.len(),
})?;
let pic_params = VaPictureParameterBufferHEVC {
curr_pic: VaPictureHEVC {
picture_id: curr_surface,
picture_id: setup_surface,
pic_order_cnt: pic.pic_order_cnt,
flags: 0,
va_reserved: [0; 4],
@@ -533,6 +536,9 @@ mod tests {
use crate::va_h265::REF_PIC_LIST_UNUSED;
use crate::va_h265::VA_PICTURE_HEVC_INVALID;
/// `SURFACE_BASE + access-unit index` — see the H.264 twin's constant.
const SURFACE_BASE: u32 = 0xa000;
const TEST_25FPS_H265: &[u8] = include_bytes!(
"../../pf-bitstream/vendor/cros-codecs/src/codec/h265/test_data/test-25fps.h265"
);
@@ -576,7 +582,9 @@ mod tests {
assert_eq!(aus.len(), expect_aus, "{label}: access-unit count");
let mut planner = H265Planner::new();
let surfaces: Vec<u32> = (0..32u32).map(|i| 0xa000 + i).collect();
// The caller's binding model — see the H.264 twin: one never-reused surface
// id per picture, bound to its slot after the conversion returns.
let mut surfaces: Vec<u32> = Vec::new();
let mut slots: Option<SlotMap> = None;
let mut saw_rps_flags = false;
let mut saw_list_entries = false;
@@ -586,8 +594,11 @@ mod tests {
.plan_au(au)
.unwrap_or_else(|e| panic!("{label} AU {index}: must plan, got {e:?}"));
let map = slots.get_or_insert_with(|| SlotMap::new(plan.picture.max_dpb_frames));
let out = plan_to_va_h265(&plan, au, map, &surfaces)
surfaces.resize(map.capacity(), crate::va::VA_INVALID_SURFACE);
let setup_surface = SURFACE_BASE + index as u32;
let out = plan_to_va_h265(&plan, au, map, &surfaces, setup_surface)
.unwrap_or_else(|e| panic!("{label} AU {index}: conversion failed: {e}"));
surfaces[usize::from(out.setup_slot)] = setup_surface;
assert_eq!(out.slices.len(), plan.slices.len());
for (n, (rec, range)) in out.slices.iter().zip(&out.slice_data).enumerate() {
+13
View File
@@ -48,6 +48,19 @@
/// one vendor and not another, so both are always written together.
pub const VA_INVALID_SURFACE: u32 = 0xffff_ffff;
/// `VABufferType` for the four buffers a decode submits, measured off real headers
/// by `layout-probe.c` rather than counted off the enum in the header.
///
/// ⚠ The last two are the trap: `VASliceParameterBufferType` is **4** and
/// `VASliceDataBufferType` is **5**, not the 3 and 4 that counting from the top
/// gives — `VABitPlaneBufferType` and `VASliceGroupMapBufferType` sit in between
/// for the codecs that need them. Getting these wrong hands the driver a slice as
/// if it were something else, which is not a decode error but a decode of garbage.
pub const VA_PICTURE_PARAMETER_BUFFER_TYPE: u32 = 0;
pub const VA_IQ_MATRIX_BUFFER_TYPE: u32 = 1;
pub const VA_SLICE_PARAMETER_BUFFER_TYPE: u32 = 4;
pub const VA_SLICE_DATA_BUFFER_TYPE: u32 = 5;
/// Flags for [`VaPictureH264::flags`].
pub const VA_PICTURE_H264_INVALID: u32 = 0x0000_0001;
pub const VA_PICTURE_H264_TOP_FIELD: u32 = 0x0000_0002;