M3 WP-2 complete. caps_h265.rs builds the profile the stream actually needs (profile idc + chroma + bit depths, all three stated on every Vulkan object) and resolves its picture format — Main to NV12, Main 10 to P010, RExt 4:4:4 to the two-plane 4:4:4 formats — validating it against the format list of every role the chosen arrangement creates images in. A Main 10 stream on an 8-bit-only device is refused BEFORE a session exists, never narrowed: decoding 10-bit into an 8-bit surface is the silent-wrongness class this crate exists to refuse. session_h265.rs adds the three-array parameters ledger; decoder_h265.rs adds VkH265Decoder, mirroring VkH264Decoder method-for-method so the client wiring is a two-arm dispatch away. H.264 and H.265 now SHARE the machinery instead of duplicating it: derive_arrangement (one coincide/distinct/layered decision table), ring::rebased_offsets (the slices-only rebase — non-VCL NALUs in the decode range hang VCN firmware), session::bind_session_memory, and a parameterised build_frame. A DecodeProfile enum replaces the bare profile idc that images.rs and ring.rs used to take: both codecs' idc types are c_uint, so handing an H.265 idc to the H.264 path COMPILED SILENTLY and built a mismatched profile chain. That is now unrepresentable. The VPS leg is the ledger's real work. The vendored parser attaches a VPS to an SPS only when it saw the NALU, and clients join live streams, so VpsSource is Parsed-or-FromSps and is stored BY VALUE: re-activating a VPS-less SPS is Current (no churn), but the real VPS arriving under the same id is a content change and RECREATES onto it, because Vulkan cannot replace a stored parameter set. Review round 10 (adversarial) confirmed the hardware-proven H.264 path is NOT regressed — derive_arrangement's check order and error identity are byte-for-byte the original, build_frame's call sites still pass the granularity-aligned extent (the 1088-row scar stays shut), and rebased_offsets reproduces the deleted inline loop for every input while moving the sum to u64 so overflow errors instead of wrapping. Also verified: the refs-order contract on every path, the RESULT_STATUS caps gate (each of reset/begin/end individually gated, no pool created when unsupported — recording one on RADV hangs its VCN), pNext lifetimes, and that no panic is reachable on stream input. Its 10 findings are fixed. The two that mattered: - A failed decode stranded a DPB slot. Once plan_to_vk_h265 had mutated the slot map, five later failure paths returned without restoring it, so planner and slot map both believed a picture was resident while no image held it — and every later AU referencing it failed, where H.264 soft-degrades and keeps delivering. Fail-closed is kept (substituting a reference silently is the corruption-hiding this program exists to end) but made RECOVERABLE: a latch flushes the planner to AwaitingIdr and resets the bindings on the next decode, which composes with the client already requesting a keyframe on every decode error. The fix deliberately covers pre-mutation failures too — those strand the picture the other way round and wedge identically. - DecodedVkFrame carried no picture format, so a Main 10 frame would decode correctly and be rendered with 8-bit transfer/range math. It now carries one, stamped from the pool so it is truthful for both decoders by construction. The presenter comment says depth 8 is because only H.264 is WIRED, not a decoder limit. Plus: bind_session_memory freed allocations before the session that may hold them was destroyed (an ordering regression from the extraction, with a SAFETY comment asserting the opposite) — the bind-stage exit now hands them back so Drop destroys first; max_level_idc is codec-tagged rather than an H.264 type carrying H.265 code points; and the decode family's videoCodecOperations is now checked, turning 'create an H.265 session on a device without the extension' from UB into a clean ladder demote. Deferred by design: no HEVC gpu_smoke/gpu_parity yet (its goldens are already in tests/data/test-25fps-h265.nv12.sha256), and no codec dispatch in the client — both later legs. Gates: fmt clean; mac clippy zero warnings, pf-vkdecode 106 + pf-bitstream 69 green; container clippy -D warnings zero for pf-client-core + pf-presenter + pf-vkdecode, tests 69/121/106 green. HARDWARE (.173, after the refactor — review saying the proven path is safe is not the GPU saying it): gpu_parity '250 frames bit-identical to libavcodec software decode' on BOTH the NVIDIA 4090 (610.88, coincide mode) and the AMD iGPU (Adrenalin 25.10.30.02, distinct mode), gpu_smoke green on both. Two independent drivers, both DPB modes, still bit-exact. The smoke trace also shows the new videoCodecOperations capture reading DECODE_H264 | DECODE_H265 | DECODE_AV1 off the real decode family.
578 lines
24 KiB
Rust
578 lines
24 KiB
Rust
//! Borrowed-device wrap: the presenter's live Vulkan handles loaded into ash
|
|
//! function tables, plus the queue-lock contract every queue submission runs under.
|
|
//!
|
|
//! Ownership: everything in [`DeviceHandles`] is BORROWED. This crate never creates
|
|
//! and never destroys the instance/device — [`DecodeDevice`]'s ash wrappers are
|
|
//! function tables over foreign handles, and dropping them destroys nothing. The
|
|
//! objects this crate does create (sessions, images, buffers, pools) are destroyed
|
|
//! by their owning structs' `Drop` impls, all of which must run before the borrowed
|
|
//! device dies — the same liveness contract FFmpeg's decoder had over the identical
|
|
//! handle bundle (`pf-client-core`'s `VulkanDecodeDevice`), now written down.
|
|
|
|
use ash::vk;
|
|
use ash::vk::Handle;
|
|
|
|
/// The borrowed handles of the presenter's decode-capable device, as raw integers so
|
|
/// the type stays FFI-plain (mirrors `pf-client-core`'s `VulkanDecodeDevice`, which
|
|
/// adapts into this in WP-C — pf-vkdecode deliberately does not depend on it).
|
|
///
|
|
/// Caller contract (checked where cheap, otherwise trusted):
|
|
/// - All four handles are live, and stay live for the lifetime of every object this
|
|
/// crate builds from them (the presenter outlives every session pump).
|
|
/// - The instance/device were created with the Vulkan Video decode stack enabled:
|
|
/// `VK_KHR_video_queue`, `VK_KHR_video_decode_queue`, and the per-codec extension
|
|
/// of every decoder that will be built on the bundle
|
|
/// (`VK_KHR_video_decode_h264` for [`crate::VkH264Decoder`],
|
|
/// `VK_KHR_video_decode_h265` for [`crate::VkH265Decoder`]), plus the
|
|
/// `synchronization2` and `timelineSemaphore` features (the presenter's device
|
|
/// meets all of this when it advertises `video_decode`).
|
|
/// - `decode_qf`/`decode_queue_index` name a queue with `VIDEO_DECODE_KHR` ops.
|
|
/// Which CODEC operations that family advertises is not trusted but READ
|
|
/// ([`DecodeDevice::decode_codec_ops`]) and each decoder refuses up front when
|
|
/// its own is missing — a physical-device caps query answers for hardware
|
|
/// regardless of which extensions the device was created with, so this is the
|
|
/// only thing standing between a wrong bundle and `vkCreateVideoSessionKHR` on
|
|
/// an unenabled codec. `graphics_qf` is the family the presenter samples on
|
|
/// (image sharing crosses the two when they differ).
|
|
#[derive(Debug, Clone)]
|
|
pub struct DeviceHandles {
|
|
/// `PFN_vkGetInstanceProcAddr` from the loader; everything else is resolved
|
|
/// through it.
|
|
pub get_instance_proc_addr: usize,
|
|
pub instance: usize,
|
|
pub physical_device: usize,
|
|
pub device: usize,
|
|
/// The video-decode queue family.
|
|
pub decode_qf: u32,
|
|
/// Queue index within `decode_qf` this decoder submits on.
|
|
pub decode_queue_index: u32,
|
|
/// The presenter's graphics+present family (the other side of image sharing).
|
|
pub graphics_qf: u32,
|
|
}
|
|
|
|
/// External synchronization for `vkQueueSubmit`: the caller supplies the lock that
|
|
/// serializes EVERY submit on the shared device — in WP-C that is pf-client-core's
|
|
/// `QueueLock`, the same object the presenter holds around its own submits/presents
|
|
/// (the 2026-07-09 `VK_ERROR_DEVICE_LOST` race is why this is a first-class contract
|
|
/// and not an afterthought). Tests use [`NoopQueueLock`].
|
|
///
|
|
/// `lock` blocks until the queue is free and takes it; `unlock` releases it. Use
|
|
/// [`QueueSubmitGuard`] rather than calling the pair by hand.
|
|
pub trait QueueLock {
|
|
fn lock(&self);
|
|
fn unlock(&self);
|
|
}
|
|
|
|
/// A [`QueueLock`] that guards nothing — for tests and for callers whose decode
|
|
/// queue is provably not shared with any other submitter.
|
|
#[derive(Debug, Default)]
|
|
pub struct NoopQueueLock;
|
|
|
|
impl QueueLock for NoopQueueLock {
|
|
fn lock(&self) {}
|
|
fn unlock(&self) {}
|
|
}
|
|
|
|
/// RAII scope over a [`QueueLock`]: acquired for exactly the duration of a queue
|
|
/// submission, released on drop (including unwinds — though this crate's own paths
|
|
/// never panic while holding it).
|
|
pub struct QueueSubmitGuard<'a> {
|
|
lock: &'a dyn QueueLock,
|
|
}
|
|
|
|
impl<'a> QueueSubmitGuard<'a> {
|
|
/// Take the queue (blocking until free).
|
|
pub fn acquire(lock: &'a dyn QueueLock) -> Self {
|
|
lock.lock();
|
|
Self { lock }
|
|
}
|
|
}
|
|
|
|
impl Drop for QueueSubmitGuard<'_> {
|
|
fn drop(&mut self) {
|
|
self.lock.unlock();
|
|
}
|
|
}
|
|
|
|
/// A [`DeviceHandles`] bundle that cannot host the decoder being built. Caller
|
|
/// bugs (a half-filled bundle) and device gaps (a decode family that does not run
|
|
/// this codec) — never stream conditions.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum DeviceError {
|
|
/// One of the four raw handles is zero/null.
|
|
NullHandle(&'static str),
|
|
/// The decode queue family advertises no decode operation for the codec the
|
|
/// decoder needs — either the device was created without that codec's
|
|
/// extension, or `decode_qf` names the wrong family. Refusing here is what
|
|
/// keeps `vkCreateVideoSessionKHR` from being called with a profile the
|
|
/// device never enabled (the caps query alone would not catch it: it asks the
|
|
/// PHYSICAL device, which answers for the hardware).
|
|
NoCodecOperation {
|
|
family: u32,
|
|
/// The codec, spelled the way the caller would recognize it
|
|
/// (`"H.264 decode"` / `"H.265 decode"`).
|
|
wanted: &'static str,
|
|
},
|
|
}
|
|
|
|
/// A device allocation that cannot proceed. Wraps the raw Vulkan failure OR the
|
|
/// memory-type miss that used to be silently papered over with index 0 — a wrong
|
|
/// type index is at best an immediate validation error and at worst a mapping of
|
|
/// the wrong heap, so a miss is an ERROR here, never a fallback.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum AllocError {
|
|
Vk(vk::Result),
|
|
/// No memory type satisfies (`type_bits`, `flags`) on this device.
|
|
NoMemoryType {
|
|
type_bits: u32,
|
|
flags: vk::MemoryPropertyFlags,
|
|
},
|
|
}
|
|
|
|
impl From<vk::Result> for AllocError {
|
|
fn from(r: vk::Result) -> Self {
|
|
AllocError::Vk(r)
|
|
}
|
|
}
|
|
|
|
/// First memory type matching `bits` and `want` — an [`AllocError::NoMemoryType`]
|
|
/// when none does (the encoder's `find_mem` falls back to 0 there; here the miss
|
|
/// surfaces).
|
|
pub(crate) fn find_memory_type(
|
|
props: &vk::PhysicalDeviceMemoryProperties,
|
|
bits: u32,
|
|
want: vk::MemoryPropertyFlags,
|
|
) -> Result<u32, AllocError> {
|
|
for i in 0..props.memory_type_count {
|
|
if (bits & (1 << i)) != 0 && props.memory_types[i as usize].property_flags.contains(want) {
|
|
return Ok(i);
|
|
}
|
|
}
|
|
Err(AllocError::NoMemoryType {
|
|
type_bits: bits,
|
|
flags: want,
|
|
})
|
|
}
|
|
|
|
/// First memory type matching `bits` that also carries `prefer`; when none does,
|
|
/// the first type matching `bits` at all. A driver constrains `memoryTypeBits` to
|
|
/// where the allocation can legally live — NVIDIA (610.88) reports some video-
|
|
/// session bindings host-visible-ONLY, which is spec-legal, so a hard `prefer`
|
|
/// requirement there is unsatisfiable by construction. Still an
|
|
/// [`AllocError::NoMemoryType`] when `bits` selects nothing whatsoever (that
|
|
/// miss-is-error contract stays; only the property preference softens). Mapped
|
|
/// staging paths (the bitstream ring) must NOT use this: they require
|
|
/// `HOST_VISIBLE|HOST_COHERENT` as a hard property, not a preference.
|
|
pub(crate) fn find_memory_type_preferring(
|
|
props: &vk::PhysicalDeviceMemoryProperties,
|
|
bits: u32,
|
|
prefer: vk::MemoryPropertyFlags,
|
|
) -> Result<u32, AllocError> {
|
|
match find_memory_type(props, bits, prefer) {
|
|
Ok(index) => Ok(index),
|
|
Err(_) => find_memory_type(props, bits, vk::MemoryPropertyFlags::empty()),
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for DeviceError {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
DeviceError::NullHandle(which) => {
|
|
write!(f, "DeviceHandles.{which} is null — a half-filled bundle")
|
|
}
|
|
DeviceError::NoCodecOperation { family, wanted } => {
|
|
write!(
|
|
f,
|
|
"decode queue family {family} advertises no {wanted} operation \
|
|
(extension not enabled on the device, or the wrong family)"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for DeviceError {}
|
|
|
|
/// The borrowed device with ash function tables loaded: the object every other
|
|
/// module in this crate makes its Vulkan calls through.
|
|
///
|
|
/// Clone is cheap-ish (ash tables are plain structs of function pointers) and safe:
|
|
/// clones share the same borrowed handles under the same liveness contract.
|
|
#[derive(Clone)]
|
|
pub struct DecodeDevice {
|
|
instance: ash::Instance,
|
|
device: ash::Device,
|
|
physical_device: vk::PhysicalDevice,
|
|
video_queue_instance: ash::khr::video_queue::Instance,
|
|
video_queue: ash::khr::video_queue::Device,
|
|
video_decode_queue: ash::khr::video_decode_queue::Device,
|
|
decode_queue: vk::Queue,
|
|
decode_qf: u32,
|
|
graphics_qf: u32,
|
|
/// The decode family advertises `queryResultStatusSupport`: per-op
|
|
/// RESULT_STATUS queries are legal in its video coding scopes. FALSE on RADV
|
|
/// (2026-08, .25: recording one anyway hangs the VCN ring) — the decoder
|
|
/// must skip queries entirely there and fall back to timeline-completion
|
|
/// verdicts.
|
|
result_status_queries: bool,
|
|
/// `VkQueueFamilyVideoPropertiesKHR::videoCodecOperations` of the decode
|
|
/// family — which codecs this queue can actually run decode ops for.
|
|
///
|
|
/// Read from the SAME `vkGetPhysicalDeviceQueueFamilyProperties2` call as the
|
|
/// status-query support (one extra chained struct, no extra round trip). It
|
|
/// is the only honest answer to "may I create a DECODE_H265 session here?":
|
|
/// `vkGetPhysicalDeviceVideoCapabilitiesKHR` is a PHYSICAL-device query and
|
|
/// succeeds on capable hardware whether or not the VkDevice was created with
|
|
/// `VK_KHR_video_decode_h265` enabled, so caps derivation alone would happily
|
|
/// lead into `vkCreateVideoSessionKHR` on an unenabled codec — undefined
|
|
/// behaviour instead of a clean demote to the next decoder rung.
|
|
///
|
|
/// Empty (no bits) is treated as "this family decodes nothing" and refuses.
|
|
/// That is safe to rely on because every driver hosting Vulkan Video fills
|
|
/// this struct — it is how applications pick a decode queue in the first
|
|
/// place (FFmpeg's own `vulkan_video.c` selects its family by exactly this
|
|
/// field, on the very drivers the shipping FFmpeg-Vulkan rung runs on).
|
|
decode_codec_ops: vk::VideoCodecOperationFlagsKHR,
|
|
}
|
|
|
|
impl DecodeDevice {
|
|
/// Load ash function tables over the borrowed handles.
|
|
///
|
|
/// # Safety
|
|
///
|
|
/// The full [`DeviceHandles`] caller contract: live handles (outliving `self`
|
|
/// and everything created through it), the video-decode extensions/features
|
|
/// enabled at creation, and truthful queue-family fields. Null handles are
|
|
/// rejected here; everything else cannot be checked and is trusted.
|
|
pub unsafe fn wrap(handles: &DeviceHandles) -> Result<Self, DeviceError> {
|
|
if handles.get_instance_proc_addr == 0 {
|
|
return Err(DeviceError::NullHandle("get_instance_proc_addr"));
|
|
}
|
|
if handles.instance == 0 {
|
|
return Err(DeviceError::NullHandle("instance"));
|
|
}
|
|
if handles.physical_device == 0 {
|
|
return Err(DeviceError::NullHandle("physical_device"));
|
|
}
|
|
if handles.device == 0 {
|
|
return Err(DeviceError::NullHandle("device"));
|
|
}
|
|
|
|
// SAFETY: the usize is non-zero (checked above) and the caller contract says
|
|
// it is the loader's PFN_vkGetInstanceProcAddr; fn pointers and usize share
|
|
// size/ABI on every supported target.
|
|
let gipa: vk::PFN_vkGetInstanceProcAddr = unsafe {
|
|
std::mem::transmute::<usize, vk::PFN_vkGetInstanceProcAddr>(
|
|
handles.get_instance_proc_addr,
|
|
)
|
|
};
|
|
// SAFETY: `gipa` is a valid Vulkan-1.0-conformant loader entry point per the
|
|
// caller contract, valid for the returned Entry's lifetime (handle liveness).
|
|
let entry = unsafe {
|
|
ash::Entry::from_static_fn(ash::StaticFn {
|
|
get_instance_proc_addr: gipa,
|
|
})
|
|
};
|
|
// SAFETY: `handles.instance` is a live VkInstance created through this very
|
|
// loader (caller contract), so loading instance-level functions against it
|
|
// is exactly the ash::Instance::load contract.
|
|
let instance = unsafe {
|
|
ash::Instance::load(
|
|
entry.static_fn(),
|
|
vk::Instance::from_raw(handles.instance as u64),
|
|
)
|
|
};
|
|
// SAFETY: `handles.device` is a live VkDevice of that instance (caller
|
|
// contract) — the ash::Device::load contract.
|
|
let device = unsafe {
|
|
ash::Device::load(
|
|
instance.fp_v1_0(),
|
|
vk::Device::from_raw(handles.device as u64),
|
|
)
|
|
};
|
|
let video_queue_instance = ash::khr::video_queue::Instance::new(&entry, &instance);
|
|
let video_queue = ash::khr::video_queue::Device::new(&instance, &device);
|
|
let video_decode_queue = ash::khr::video_decode_queue::Device::new(&instance, &device);
|
|
// SAFETY: the caller contract guarantees `decode_qf`/`decode_queue_index`
|
|
// name a queue the device was created with.
|
|
let decode_queue =
|
|
unsafe { device.get_device_queue(handles.decode_qf, handles.decode_queue_index) };
|
|
|
|
// The two per-family video facts, from ONE query: whether RESULT_STATUS
|
|
// queries are legal here, and which codec operations this family can run
|
|
// (struct field docs for both). An out-of-range decode family — a bundle
|
|
// naming a queue this physical device does not have — answers "no" to
|
|
// both, and the codec check then refuses the decoder outright.
|
|
let physical_device = vk::PhysicalDevice::from_raw(handles.physical_device as u64);
|
|
// SAFETY: live physical device (caller contract); the two-call form fills
|
|
// the chained per-family structs.
|
|
let family_count =
|
|
unsafe { instance.get_physical_device_queue_family_properties2_len(physical_device) };
|
|
let (result_status_queries, decode_codec_ops) = if (handles.decode_qf as usize)
|
|
< family_count
|
|
{
|
|
let mut status_props =
|
|
vec![vk::QueueFamilyQueryResultStatusPropertiesKHR::default(); family_count];
|
|
let mut video_props = vec![vk::QueueFamilyVideoPropertiesKHR::default(); family_count];
|
|
let mut families: Vec<vk::QueueFamilyProperties2<'_>> = status_props
|
|
.iter_mut()
|
|
.zip(video_props.iter_mut())
|
|
.map(|(status, video)| {
|
|
vk::QueueFamilyProperties2::default()
|
|
.push_next(status)
|
|
.push_next(video)
|
|
})
|
|
.collect();
|
|
// SAFETY: as above, arrays sized to the reported count.
|
|
unsafe {
|
|
instance
|
|
.get_physical_device_queue_family_properties2(physical_device, &mut families)
|
|
};
|
|
drop(families);
|
|
let family = handles.decode_qf as usize;
|
|
(
|
|
status_props[family].query_result_status_support != vk::FALSE,
|
|
video_props[family].video_codec_operations,
|
|
)
|
|
} else {
|
|
(false, vk::VideoCodecOperationFlagsKHR::NONE)
|
|
};
|
|
|
|
// `entry` is only the ladder the tables above were loaded through; nothing
|
|
// needs it afterwards (ash tables own their function pointers).
|
|
drop(entry);
|
|
|
|
Ok(Self {
|
|
instance,
|
|
device,
|
|
physical_device,
|
|
video_queue_instance,
|
|
video_queue,
|
|
video_decode_queue,
|
|
decode_queue,
|
|
decode_qf: handles.decode_qf,
|
|
graphics_qf: handles.graphics_qf,
|
|
result_status_queries,
|
|
decode_codec_ops,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn ash(&self) -> &ash::Device {
|
|
&self.device
|
|
}
|
|
|
|
/// Whether the decode family supports per-op RESULT_STATUS queries (struct
|
|
/// field docs — FALSE on RADV, where recording one hangs the VCN).
|
|
pub(crate) fn result_status_queries(&self) -> bool {
|
|
self.result_status_queries
|
|
}
|
|
|
|
/// The codec operations the decode family advertises (struct field docs).
|
|
pub fn decode_codec_ops(&self) -> vk::VideoCodecOperationFlagsKHR {
|
|
self.decode_codec_ops
|
|
}
|
|
|
|
/// Refuse unless the decode family advertises `op`.
|
|
///
|
|
/// The decoders' first act, before any caps query: a physical-device caps
|
|
/// query answers for the HARDWARE and would happily green-light a codec the
|
|
/// VkDevice never enabled the extension for, at which point
|
|
/// `vkCreateVideoSessionKHR` is undefined behaviour. This turns that into the
|
|
/// ladder's clean, named demote.
|
|
pub(crate) fn require_codec_op(
|
|
&self,
|
|
op: vk::VideoCodecOperationFlagsKHR,
|
|
what: &'static str,
|
|
) -> Result<(), DeviceError> {
|
|
if self.decode_codec_ops.contains(op) {
|
|
Ok(())
|
|
} else {
|
|
Err(DeviceError::NoCodecOperation {
|
|
family: self.decode_qf,
|
|
wanted: what,
|
|
})
|
|
}
|
|
}
|
|
|
|
pub(crate) fn physical_device(&self) -> vk::PhysicalDevice {
|
|
self.physical_device
|
|
}
|
|
|
|
pub(crate) fn video_queue_instance(&self) -> &ash::khr::video_queue::Instance {
|
|
&self.video_queue_instance
|
|
}
|
|
|
|
pub(crate) fn video_queue(&self) -> &ash::khr::video_queue::Device {
|
|
&self.video_queue
|
|
}
|
|
|
|
pub(crate) fn video_decode_queue(&self) -> &ash::khr::video_decode_queue::Device {
|
|
&self.video_decode_queue
|
|
}
|
|
|
|
pub(crate) fn decode_queue(&self) -> vk::Queue {
|
|
self.decode_queue
|
|
}
|
|
|
|
pub(crate) fn decode_qf(&self) -> u32 {
|
|
self.decode_qf
|
|
}
|
|
|
|
/// The queue families image sharing spans: empty (EXCLUSIVE) when decode and
|
|
/// graphics are one family, both otherwise (CONCURRENT — the presenter samples
|
|
/// decode output on its own family and per-frame ownership transfers would buy
|
|
/// latency for nothing at punktfunk's frame rates).
|
|
pub(crate) fn sharing_families(&self) -> Vec<u32> {
|
|
if self.decode_qf == self.graphics_qf {
|
|
Vec::new()
|
|
} else {
|
|
vec![self.decode_qf, self.graphics_qf]
|
|
}
|
|
}
|
|
|
|
/// The device's memory properties (queried fresh; cheap and stateless).
|
|
pub(crate) fn memory_properties(&self) -> vk::PhysicalDeviceMemoryProperties {
|
|
// SAFETY: `physical_device` is live per the DeviceHandles contract; the call
|
|
// fills a plain struct and touches nothing else.
|
|
unsafe {
|
|
self.instance
|
|
.get_physical_device_memory_properties(self.physical_device)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_half_filled_bundle_is_rejected_before_any_ffi() {
|
|
let mut handles = DeviceHandles {
|
|
get_instance_proc_addr: 0,
|
|
instance: 1,
|
|
physical_device: 1,
|
|
device: 1,
|
|
decode_qf: 0,
|
|
decode_queue_index: 0,
|
|
graphics_qf: 0,
|
|
};
|
|
// SAFETY: wrap rejects the null handle before making any Vulkan call, so no
|
|
// part of the liveness contract is exercised. (`Err` matched by hand: the
|
|
// Ok side holds ash tables, which carry no Debug for unwrap_err.)
|
|
let result = unsafe { DecodeDevice::wrap(&handles) };
|
|
let Err(err) = result else {
|
|
panic!("a null gipa must be rejected")
|
|
};
|
|
assert_eq!(err, DeviceError::NullHandle("get_instance_proc_addr"));
|
|
|
|
handles.get_instance_proc_addr = 1;
|
|
handles.device = 0;
|
|
// SAFETY: as above — the null device handle is rejected before any FFI.
|
|
let result = unsafe { DecodeDevice::wrap(&handles) };
|
|
let Err(err) = result else {
|
|
panic!("a null device must be rejected")
|
|
};
|
|
assert_eq!(err, DeviceError::NullHandle("device"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_memory_type_miss_is_an_error_never_a_fallback_to_index_zero() {
|
|
let mut props = vk::PhysicalDeviceMemoryProperties {
|
|
memory_type_count: 2,
|
|
..Default::default()
|
|
};
|
|
props.memory_types[0].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
|
|
props.memory_types[1].property_flags =
|
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
|
|
|
|
// A hit resolves to the matching index, not the first.
|
|
assert_eq!(
|
|
find_memory_type(
|
|
&props,
|
|
0b11,
|
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT
|
|
),
|
|
Ok(1)
|
|
);
|
|
// A type excluded by the requirement bits does not count as a hit.
|
|
assert_eq!(
|
|
find_memory_type(&props, 0b01, vk::MemoryPropertyFlags::HOST_VISIBLE),
|
|
Err(AllocError::NoMemoryType {
|
|
type_bits: 0b01,
|
|
flags: vk::MemoryPropertyFlags::HOST_VISIBLE
|
|
})
|
|
);
|
|
// Flags nothing advertises: an error carrying the miss, never index 0.
|
|
assert_eq!(
|
|
find_memory_type(&props, 0b11, vk::MemoryPropertyFlags::PROTECTED),
|
|
Err(AllocError::NoMemoryType {
|
|
type_bits: 0b11,
|
|
flags: vk::MemoryPropertyFlags::PROTECTED
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn preferring_picks_the_preferred_type_and_falls_back_inside_the_bits() {
|
|
let mut props = vk::PhysicalDeviceMemoryProperties {
|
|
memory_type_count: 4,
|
|
..Default::default()
|
|
};
|
|
props.memory_types[0].property_flags =
|
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
|
|
props.memory_types[1].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
|
|
props.memory_types[2].property_flags = vk::MemoryPropertyFlags::DEVICE_LOCAL;
|
|
props.memory_types[3].property_flags =
|
|
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT;
|
|
|
|
// The preferred property wins over a lower-indexed non-preferred type.
|
|
assert_eq!(
|
|
find_memory_type_preferring(&props, 0b0011, vk::MemoryPropertyFlags::DEVICE_LOCAL),
|
|
Ok(1)
|
|
);
|
|
// The NVIDIA session-binding shape: `memoryTypeBits` names only a
|
|
// host-visible type — honor the bits instead of erroring.
|
|
assert_eq!(
|
|
find_memory_type_preferring(&props, 0b1000, vk::MemoryPropertyFlags::DEVICE_LOCAL),
|
|
Ok(3)
|
|
);
|
|
// Bits selecting nothing remain a hard miss, never index 0.
|
|
assert_eq!(
|
|
find_memory_type_preferring(&props, 0b0000, vk::MemoryPropertyFlags::DEVICE_LOCAL),
|
|
Err(AllocError::NoMemoryType {
|
|
type_bits: 0b0000,
|
|
flags: vk::MemoryPropertyFlags::empty()
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_queue_submit_guard_brackets_the_lock() {
|
|
use std::sync::atomic::AtomicI32;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
#[derive(Default)]
|
|
struct CountingLock {
|
|
depth: AtomicI32,
|
|
peak: AtomicI32,
|
|
}
|
|
impl QueueLock for CountingLock {
|
|
fn lock(&self) {
|
|
let d = self.depth.fetch_add(1, Ordering::SeqCst) + 1;
|
|
self.peak.fetch_max(d, Ordering::SeqCst);
|
|
}
|
|
fn unlock(&self) {
|
|
self.depth.fetch_sub(1, Ordering::SeqCst);
|
|
}
|
|
}
|
|
|
|
let lock = CountingLock::default();
|
|
{
|
|
let _guard = QueueSubmitGuard::acquire(&lock);
|
|
assert_eq!(lock.depth.load(Ordering::SeqCst), 1);
|
|
}
|
|
assert_eq!(lock.depth.load(Ordering::SeqCst), 0, "released on drop");
|
|
assert_eq!(lock.peak.load(Ordering::SeqCst), 1);
|
|
}
|
|
}
|