pf-presenter's 120 sites are `ash` calls almost without exception, so 120 independent arguments
would have been 120 restatements of the signature — the exact noise this program exists to remove.
They get the `abi.rs` treatment instead: the Vulkan contract stated once in `lib.rs`, each site
naming which of three shapes it is.
The three are not equal, and separating them is the point. CREATE and RECORD carry no real
precondition — the device is owned, the builders are locals, nothing executes until submit. DESTROY
does: the GPU must not still be using the object, and that is established by the path (a fence wait,
a `queue_wait_idle`, a retired swapchain), not by the call. Those sites say so, because getting it
wrong is a use-after-free no type catches. The contract also tells the next person that a block
outside the three shapes needs a real proof, and that writing "as above" is the signal it doesn't
belong in them.
punktfunk-core's Windows half is finished here too: `qos_windows.rs`'s `GetLastError` reads (called
before anything can reset the thread's error slot) and `udp/windows.rs`'s control-message write,
whose argument is that `ctrl` is sized by `WSA_CMSG_SPACE(4)` — computed two lines up — so header
plus payload cannot run past it, and `write_unaligned` is used because `WSA_CMSG_DATA` offers no
alignment guarantee.
⚠️ THE WINDOWS BLIND SPOT BIT A THIRD TIME. A Linux measurement put this crate pair at 113; the
real number was 129 — `d3d11.rs`, `win32.rs`, `qos_windows.rs`, `udp/windows.rs` are all
`cfg`-hidden. Every crate in this sweep had to be finished on .47 after being "done" on .21. For a
cross-platform crate the Linux number is a lower bound, never the answer.
All three crates now deny `undocumented_unsafe_blocks`, which was the goal: it applied to 8 of 11
crates carrying unsafe, and the three exempt ones held 381 items between them — including the C ABI
surface and the presenter. Verified: Linux .21 fmt + both CI clippy steps rc=0; Windows .47 all four
closed crates clippy `-D warnings` rc=0 plus the full Windows CI clippy set and pf-capture's tests.
Only small crates remain unguarded (75 items total, largest 26).
294 lines
15 KiB
Rust
294 lines
15 KiB
Rust
//! Swapchain recreate / resize / HDR reconfiguration.
|
|
|
|
use super::setup::pick_formats;
|
|
use super::{OverlayPipe, Presenter};
|
|
use crate::csc::CscPass;
|
|
use anyhow::{anyhow, Context as _, Result};
|
|
use ash::vk;
|
|
|
|
/// Extra guidance appended to a swapchain-creation failure when SDL is on the KMSDRM backend —
|
|
/// i.e. a compositor-less kiosk/embedded run, where the bare Vulkan error is close to useless.
|
|
///
|
|
/// Measured on an NVIDIA proprietary + KMSDRM box: SDL opens the card, Vulkan enumerates the GPU
|
|
/// *and* the display (`VK_KHR_display` reports the connected HDMI connector), then
|
|
/// `vkCreateSwapchainKHR` returns ERROR_INITIALIZATION_FAILED — as root, with `nvidia_drm.modeset`
|
|
/// on, on a card no compositor was using. So it is neither permissions nor DRM master: NVIDIA's
|
|
/// direct-to-display path wants the display leased (`vkAcquireDrmDisplayEXT`) and SDL's KMSDRM
|
|
/// surface path does not do that for it. Empty on every other backend, where the message would be
|
|
/// noise.
|
|
fn kmsdrm_swapchain_hint() -> String {
|
|
let kmsdrm = std::env::var("SDL_VIDEODRIVER").is_ok_and(|v| v.eq_ignore_ascii_case("kmsdrm"));
|
|
if !kmsdrm {
|
|
return String::new();
|
|
}
|
|
let card = std::env::var("PUNKTFUNK_DRM_CARD").unwrap_or_else(|_| "unset".into());
|
|
format!(
|
|
" — under SDL_VIDEODRIVER=kmsdrm (PUNKTFUNK_DRM_CARD={card}). Check, in order: the card \
|
|
has a CONNECTED connector (`cat /sys/class/drm/card*-*/status`); nothing else holds DRM \
|
|
master on it (a running compositor does — pin another card with PUNKTFUNK_DRM_CARD=<n>); \
|
|
and the driver is Mesa. NVIDIA's proprietary direct-display path is known to fail here \
|
|
even as root with a display Vulkan can enumerate."
|
|
)
|
|
}
|
|
|
|
impl Presenter {
|
|
/// (Re)build the swapchain for the window's current pixel size. Also the resize path.
|
|
pub fn recreate_swapchain(&mut self, window: &sdl3::video::Window) -> Result<()> {
|
|
self.quiesce_own()?;
|
|
// Drain the queue before touching presentation objects: after this, every prior
|
|
// present's semaphore-wait operation has completed, so the OLD swapchain and its
|
|
// render semaphores are safe to destroy immediately below. (The previous scheme
|
|
// parked them and destroyed after one fence cycle — but the fence proves only
|
|
// OUR submit, not the presentation engine's semaphore consumption:
|
|
// VUID-vkDestroySemaphore-05149 / VUID-vkDestroySwapchainKHR-01282 on every
|
|
// recreate, and destroy-in-use is exactly the kind of misuse that turns into an
|
|
// intermittent VK_ERROR_DEVICE_LOST.) Safe against the pump's FFmpeg submits —
|
|
// both sides hold the shared queue lock — and cheap: a recreate already stalls
|
|
// the stream for a frame, and only happens on resize/HDR-flip/OUT_OF_DATE.
|
|
{
|
|
let _q = self.queue_lock.guard();
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by
|
|
// this type and live for the call, and every builder struct is a local that outlives
|
|
// it.
|
|
unsafe { self.device.queue_wait_idle(self.queue) }
|
|
.context("vkQueueWaitIdle (swapchain recreate)")?;
|
|
}
|
|
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
let caps = unsafe {
|
|
self.surface_i
|
|
.get_physical_device_surface_capabilities(self.pdev, self.surface)
|
|
}?;
|
|
let (pw, ph) = window.size_in_pixels();
|
|
let extent = if caps.current_extent.width != u32::MAX {
|
|
caps.current_extent
|
|
} else {
|
|
vk::Extent2D {
|
|
width: pw.clamp(caps.min_image_extent.width, caps.max_image_extent.width),
|
|
height: ph.clamp(caps.min_image_extent.height, caps.max_image_extent.height),
|
|
}
|
|
};
|
|
if extent.width == 0 || extent.height == 0 {
|
|
// Minimized — keep the old swapchain; presents will report OUT_OF_DATE and
|
|
// land back here once the window has a size again.
|
|
return Ok(());
|
|
}
|
|
let mut min_images = caps.min_image_count + 1;
|
|
if caps.max_image_count > 0 {
|
|
min_images = min_images.min(caps.max_image_count);
|
|
}
|
|
|
|
let old = self.swapchain;
|
|
let info = vk::SwapchainCreateInfoKHR::default()
|
|
.surface(self.surface)
|
|
.min_image_count(min_images)
|
|
.image_format(self.format.format)
|
|
.image_color_space(self.format.color_space)
|
|
.image_extent(extent)
|
|
.image_array_layers(1)
|
|
// TRANSFER_DST is the whole phase-1 pipeline (clear + blit); COLOR_ATTACHMENT
|
|
// keeps the phase-2 render pass from forcing a swapchain rebuild contract change.
|
|
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST)
|
|
.image_sharing_mode(vk::SharingMode::EXCLUSIVE)
|
|
.pre_transform(caps.current_transform)
|
|
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
|
|
.present_mode(self.present_mode)
|
|
.clipped(true)
|
|
.old_swapchain(old);
|
|
// SAFETY: per the Vulkan contract above - a create/allocate call on the live device, over
|
|
// builder structs that are locals outliving the call; the handle it returns is owned by
|
|
// the value being built here.
|
|
let swapchain = unsafe { self.swap_d.create_swapchain(&info, None) }
|
|
.map_err(|e| anyhow!("vkCreateSwapchainKHR: {e}{}", kmsdrm_swapchain_hint()))?;
|
|
// The old swapchain and everything tied to its images dies NOW: the fence
|
|
// quiesce covered our own command buffers, the queue drain above covered the
|
|
// presentation engine's semaphore waits — nothing can still reference them.
|
|
// The present-wait waiter is the one remaining referent: `vkWaitForPresentKHR`
|
|
// requires the swapchain alive for the call, so drain it first (bounded by the
|
|
// waiter's 250 ms cap; ids complete in order so this is normally instant).
|
|
if let Some(t) = &self.present_timer {
|
|
t.drain();
|
|
}
|
|
// An unclaimed last present belonged to the dying swapchain — drop the claim.
|
|
self.last_presented = None;
|
|
let (overlay_views, overlay_framebuffers) = self.overlay_pipe.take_targets();
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
unsafe {
|
|
for fb in overlay_framebuffers {
|
|
self.device.destroy_framebuffer(fb, None);
|
|
}
|
|
for v in overlay_views {
|
|
self.device.destroy_image_view(v, None);
|
|
}
|
|
for s in self.render_sems.drain(..) {
|
|
self.device.destroy_semaphore(s, None);
|
|
}
|
|
if old != vk::SwapchainKHR::null() {
|
|
self.swap_d.destroy_swapchain(old, None);
|
|
}
|
|
}
|
|
self.swapchain = swapchain;
|
|
// SAFETY: per the Vulkan contract above - a read-only query on the live instance/device,
|
|
// filling locals returned by value.
|
|
self.images = unsafe { self.swap_d.get_swapchain_images(swapchain) }?;
|
|
self.extent = extent;
|
|
self.overlay_pipe.rebuild_targets(
|
|
&self.device,
|
|
&self.images,
|
|
self.format.format,
|
|
extent,
|
|
)?;
|
|
|
|
for _ in 0..self.images.len() {
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by
|
|
// this type and live for the call, and every builder struct is a local that outlives
|
|
// it.
|
|
self.render_sems.push(unsafe {
|
|
self.device
|
|
.create_semaphore(&vk::SemaphoreCreateInfo::default(), None)
|
|
}?);
|
|
}
|
|
tracing::debug!(
|
|
width = extent.width,
|
|
height = extent.height,
|
|
images = self.images.len(),
|
|
"swapchain (re)created"
|
|
);
|
|
// HDR metadata is per-swapchain state: a rebuilt HDR10 swapchain needs it pushed
|
|
// again (this also covers set_hdr_mode's entry into HDR10, which lands here).
|
|
if self.hdr_active {
|
|
self.apply_hdr_metadata();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Whether the swapchain is actually in HDR10/PQ mode — as opposed to a PQ stream
|
|
/// being tone-mapped onto an SDR surface. This, not the stream's own signaling, is
|
|
/// what user-facing "HDR" indicators should report.
|
|
pub fn hdr_active(&self) -> bool {
|
|
self.hdr_active
|
|
}
|
|
|
|
/// Record the host's ST.2086 mastering + content-light metadata (the 0xCE plane),
|
|
/// pushing it to the swapchain immediately when HDR10 mode is live. Cheap and
|
|
/// idempotent per distinct value — callers just drain the plane into it.
|
|
pub fn set_hdr_metadata(&mut self, meta: punktfunk_core::quic::HdrMeta) {
|
|
if self.hdr_meta == Some(meta) {
|
|
return;
|
|
}
|
|
self.hdr_meta = Some(meta);
|
|
if self.hdr_active {
|
|
self.apply_hdr_metadata();
|
|
}
|
|
}
|
|
|
|
/// Push the current metadata (the host's, or a generic HDR10 baseline until 0xCE
|
|
/// arrives) to the presentation engine via `vkSetHdrMetadataEXT`. Compositors gate
|
|
/// their HDR-app signaling on this — picking the HDR10 colorspace alone leaves
|
|
/// gamescope treating the app as SDR (no SteamOS HDR badge, no per-app tone-map
|
|
/// target). No-op where the driver lacks the extension.
|
|
fn apply_hdr_metadata(&self) {
|
|
let Some(ext) = &self.hdr_metadata_d else {
|
|
return;
|
|
};
|
|
// Same generic baseline as the Windows presenter: BT.2020 primaries + D65
|
|
// white, 1000-nit mastering display, MaxCLL 1000 / MaxFALL 400.
|
|
let m = self.hdr_meta.unwrap_or(punktfunk_core::quic::HdrMeta {
|
|
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
|
white_point: [15635, 16450],
|
|
max_display_mastering_luminance: 10_000_000,
|
|
min_display_mastering_luminance: 1,
|
|
max_cll: 1000,
|
|
max_fall: 400,
|
|
});
|
|
// Protocol fields are HDR10 SEI fixed-point (chromaticity 1/50000, luminance
|
|
// 0.0001 cd/m², primaries in ST.2086 G,B,R order); Vulkan wants floats in
|
|
// 0..1 chromaticity and whole nits, primaries named R/G/B.
|
|
let xy = |p: [u16; 2]| vk::XYColorEXT {
|
|
x: p[0] as f32 / 50_000.0,
|
|
y: p[1] as f32 / 50_000.0,
|
|
};
|
|
let [g, b, r] = m.display_primaries;
|
|
let md = vk::HdrMetadataEXT::default()
|
|
.display_primary_red(xy(r))
|
|
.display_primary_green(xy(g))
|
|
.display_primary_blue(xy(b))
|
|
.white_point(xy(m.white_point))
|
|
.max_luminance(m.max_display_mastering_luminance as f32 / 10_000.0)
|
|
.min_luminance(m.min_display_mastering_luminance as f32 / 10_000.0)
|
|
.max_content_light_level(m.max_cll as f32)
|
|
.max_frame_average_light_level(m.max_fall as f32);
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
unsafe { ext.set_hdr_metadata(&[self.swapchain], &[md]) };
|
|
tracing::debug!(from_host = self.hdr_meta.is_some(), "HDR metadata pushed");
|
|
}
|
|
/// Flip the presenter between SDR and HDR10 output (stream SDR↔PQ, in-band). A
|
|
/// fence quiesce, then everything format-bound is rebuilt: the CSC pass + video
|
|
/// image (10-bit intermediate — PQ in 8 bits bands visibly), the overlay pipe, and
|
|
/// the swapchain (old one parked per the deferred-destroy rules).
|
|
pub(super) fn set_hdr_mode(&mut self, window: &sdl3::video::Window, on: bool) -> Result<()> {
|
|
let target = if on {
|
|
self.hdr10_format.expect("caller checked availability")
|
|
} else {
|
|
// Recompute the SDR pick? It never changed — the sdr format is immutable.
|
|
// (self.format currently holds the HDR pairing.)
|
|
pick_formats(&self.surface_i, self.pdev, self.surface, false)?.0
|
|
};
|
|
tracing::info!(hdr = on, format = ?target, "switching presentation mode");
|
|
self.quiesce_own()?;
|
|
self.video_format = if on {
|
|
vk::Format::A2B10G10R10_UNORM_PACK32
|
|
} else {
|
|
vk::Format::R8G8B8A8_UNORM
|
|
};
|
|
self.csc.destroy(&self.device); // fence-safe: only our cmd bufs reference it
|
|
self.csc = CscPass::new(&self.device, self.video_format)?;
|
|
// The planar (PyroWave) pass renders to the same intermediate — rebuild it at the
|
|
// new format too (an HDR pyrowave session needs the 10-bit intermediate exactly
|
|
// like the H.26x path; 8-bit PQ bands visibly).
|
|
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
|
if let Some(p) = self.csc_planar.take() {
|
|
p.destroy(&self.device);
|
|
self.csc_planar = Some(CscPass::new_planar(&self.device, self.video_format)?);
|
|
}
|
|
if let Some(v) = self.video.take() {
|
|
// SAFETY: per the Vulkan contract above - this destroys objects this type owns, and
|
|
// the GPU is known idle for them (the fence/queue-wait on the path here, or the
|
|
// swapchain being retired), which is the obligation that makes a destroy sound rather
|
|
// than the handle merely being non-null.
|
|
unsafe {
|
|
self.device.destroy_framebuffer(v.framebuffer, None);
|
|
self.device.destroy_image_view(v.view, None);
|
|
self.device.destroy_image(v.image, None);
|
|
self.device.free_memory(v.memory, None);
|
|
}
|
|
}
|
|
// New overlay pipe against the new swapchain format. The old one's targets
|
|
// (views/framebuffers over the current swapchain's images) are only ever
|
|
// referenced by our own command buffers — the fence quiesce above makes them
|
|
// safe to destroy right here; the swapchain itself rides the recreate below.
|
|
let mut old_pipe = std::mem::replace(
|
|
&mut self.overlay_pipe,
|
|
OverlayPipe::new(&self.device, target.format)?,
|
|
);
|
|
let (overlay_views, overlay_framebuffers) = old_pipe.take_targets();
|
|
// SAFETY: per the Vulkan contract above - the Vulkan handles used here are owned by this
|
|
// type and live for the call, and every builder struct is a local that outlives it.
|
|
unsafe {
|
|
for fb in overlay_framebuffers {
|
|
self.device.destroy_framebuffer(fb, None);
|
|
}
|
|
for v in overlay_views {
|
|
self.device.destroy_image_view(v, None);
|
|
}
|
|
}
|
|
old_pipe.destroy(&self.device);
|
|
self.format = target;
|
|
self.hdr_active = on;
|
|
self.recreate_swapchain(window)
|
|
}
|
|
}
|