feat: nescapture pacing, improvements and deps update (#334)

Make nescapture better with proper queue family checks, FPS limiting,
semaphore usage and other.. also updated deps like pollster and
pixelforge.

<!-- greptile_comment -->

<!-- greptile_summary -->

<h2><a
href="https://app.greptile.com/api/retrigger?id=64083904"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/RetriggerDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"><img
alt="Retrigger"
src="https://greptile-static-assets.s3.amazonaws.com/badges/Retrigger.svg?v=1"
align="right"></picture></a>Confidence Score: 5/5</h2>

The final review contains no accepted findings, so the PR appears safe
to merge.

<h3>Summary</h3>

- This PR reworks `nescapture` frame capture around a four-slot DMA-BUF
ring, tracks presentation queue families, adds semaphore-based
capture/present ordering, introduces capture frame-rate pacing, carries
presentation timestamps through encoding, and updates Vulkan-related
dependencies.

<sub>Reviews (1) · Last reviewed commit: ["feat: Update deps and remove
deprecated
..."](79e21f4aac)</sub>

<!-- /greptile_comment -->

---------

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Ollikainen
2026-09-15 13:58:57 +03:00
committed by GitHub
parent 8246aa5538
commit 15ad60bf49
13 changed files with 1045 additions and 465 deletions

View File

@@ -12,7 +12,7 @@ crate-type = ["cdylib"]
[dependencies]
# Vulkan bindings
ash = { git = "https://github.com/ash-rs/ash", rev = "55dd56906bbb5760e9e9e6c56f45be67f67e0649" }
ash = { git = "https://github.com/ash-rs/ash", rev = "f4c2ca3e4f6b998d5254ad101a32f024d87cdec2" }
# Shader fingerprinting
sha2 = "0.10"
bytemuck = "1"
@@ -20,7 +20,7 @@ bytemuck = "1"
# Concurrent state maps
dashmap = "6"
once_cell = "1"
pollster = "0.4.0"
pollster = "1.0"
# Logging
log = "0.4"
@@ -32,11 +32,10 @@ toml = "0.8"
serde = { version = "1", features = ["derive"] }
# Vulkan Video hardware encoding.
pixelforge = { git = "https://github.com/hgaiser/pixelforge.git", rev = "2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379", features = ["dmabuf"] }
pixelforge = { git = "https://github.com/hgaiser/pixelforge.git", rev = "936d412e1a73917e0e108c4ab18bf5208b1681ac", features = ["dmabuf"] }
# libc for DMA-BUF OS primitives
libc = "0.2"
# Shared IPC protocol
nesprotocol = { path = "../../crates/nesprotocol" }

View File

@@ -1,19 +1,17 @@
// ─────────────────────────────────────────────────────────────────────────────
// capture.rs — Frame capture helpers
//
// final_image is allocated with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT
// Each ring slot is allocated with VK_EXTERNAL_MEMORY_HANDLE_TYPE_DMA_BUF_BIT_EXT
// so that after the GPU blit we can export an fd and import it into pixelforge's
// separate VkDevice for zero-copy hardware encoding via DmaBufImporter.
//
// After ensure_final_image allocates (or re-allocates) the image, we query
// its SubresourceLayout and cache the row stride in DeviceState::final_stride.
// The stride is needed by the encoder to correctly import the LINEAR image.
// The row stride comes from the image's SubresourceLayout, queried once at
// allocation; the encoder needs it to import the LINEAR image correctly.
// ─────────────────────────────────────────────────────────────────────────────
use crate::state::{CB_STATE, CaptureResources, DEVICE_STATE};
use crate::state::{CB_STATE, CAPTURE_SLOTS, CaptureRing, CaptureSlot, DEVICE_STATE};
use ash::vk::{self, Handle};
use std::os::raw::c_int;
use std::sync::atomic::Ordering;
fn make_subresource_range() -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange {
@@ -216,12 +214,8 @@ unsafe fn alloc_image(
// ── Stride query ──────────────────────────────────────────────────────────────
/// Query and cache the row stride of final_image.
/// Returns stride in bytes; 0 on failure.
pub unsafe fn query_and_cache_final_stride(
ds: &crate::state::DeviceState,
image: vk::Image,
) -> u32 {
/// Row stride in bytes of a LINEAR image, or 0 on failure.
pub unsafe fn query_stride(ds: &crate::state::DeviceState, image: vk::Image) -> u32 {
let subresource = vk::ImageSubresource {
aspect_mask: vk::ImageAspectFlags::COLOR,
mip_level: 0,
@@ -235,9 +229,7 @@ pub unsafe fn query_and_cache_final_stride(
depth_pitch: 0,
};
unsafe { (ds.fp.get_image_subresource_layout)(ds.raw, image, &subresource, &mut layout) };
let stride = layout.row_pitch as u32;
ds.final_stride.store(stride, Ordering::Relaxed);
stride
layout.row_pitch as u32
}
// ── DMA-BUF fd export ─────────────────────────────────────────────────────────
@@ -296,30 +288,183 @@ pub unsafe fn ensure_hudless_image(ds: &crate::state::DeviceState, w: u32, h: u3
}
}
pub unsafe fn ensure_final_image(ds: &crate::state::DeviceState, w: u32, h: u32, f: vk::Format) {
let mut ig = ds.final_image.lock().unwrap();
let mut mg = ds.final_memory.lock().unwrap();
let mut sg = ds.final_size.lock().unwrap();
if let (Some(i), Some(m)) = (*ig, *mg) {
let (ew, eh, ef) = *sg;
if ew >= w && eh >= h && ef == f {
return;
// ── Capture ring lifecycle ────────────────────────────────────────────────────
/// Bring `ring` up to a ring able to hold `w`x`h` `f` frames, building or
/// rebuilding it as needed. Returns false when the caller should skip this
/// frame.
///
/// A rebuild destroys images the encoder may still be reading, so it only
/// happens when every slot has come back. Resolution changes are rare and one
/// dropped frame at a resize is not worth a use-after-free.
unsafe fn ensure_capture_ring(
ds: &crate::state::DeviceState,
ring: &mut Option<CaptureRing>,
w: u32,
h: u32,
f: vk::Format,
queue_family: u32,
) -> bool {
if let Some(existing) = ring.as_ref() {
let (ew, eh, ef) = existing.size;
if ew >= w && eh >= h && ef == f && existing.queue_family == queue_family {
return true;
}
if !ds.capture_slots.all_free() {
return false;
}
if let Some(old) = ring.take() {
unsafe { destroy_capture_ring(ds, old) };
}
unsafe { (ds.fp.destroy_image)(ds.raw, i, std::ptr::null()) };
unsafe { (ds.fp.free_memory)(ds.raw, m, std::ptr::null()) };
*ig = None;
*mg = None;
ds.final_stride.store(0, Ordering::Relaxed);
}
if let Some((i, m)) = unsafe { allocate_dmabuf_image(ds, w, h, f, "final") } {
// Query stride immediately after allocation so it's available on first frame.
unsafe { query_and_cache_final_stride(ds, i) };
*ig = Some(i);
*mg = Some(m);
*sg = (w, h, f);
match unsafe { create_capture_ring(ds, w, h, f, queue_family) } {
Some(fresh) => {
*ring = Some(fresh);
true
}
None => false,
}
}
unsafe fn create_capture_ring(
ds: &crate::state::DeviceState,
w: u32,
h: u32,
f: vk::Format,
queue_family: u32,
) -> Option<CaptureRing> {
let pci = vk::CommandPoolCreateInfo {
s_type: vk::StructureType::COMMAND_POOL_CREATE_INFO,
p_next: std::ptr::null(),
flags: vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER,
// Was hard-coded to family 0, which is only correct when the game
// happens to present on family 0.
queue_family_index: queue_family,
_marker: std::marker::PhantomData,
};
let mut command_pool = vk::CommandPool::null();
if unsafe { (ds.fp.create_command_pool)(ds.raw, &pci, std::ptr::null(), &mut command_pool) }
!= vk::Result::SUCCESS
{
return None;
}
let ai = vk::CommandBufferAllocateInfo {
s_type: vk::StructureType::COMMAND_BUFFER_ALLOCATE_INFO,
p_next: std::ptr::null(),
command_pool,
level: vk::CommandBufferLevel::PRIMARY,
command_buffer_count: CAPTURE_SLOTS as u32,
_marker: std::marker::PhantomData,
};
let mut cbs = [vk::CommandBuffer::null(); CAPTURE_SLOTS];
if unsafe { (ds.fp.allocate_command_buffers)(ds.raw, &ai, cbs.as_mut_ptr()) }
!= vk::Result::SUCCESS
{
unsafe { (ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null()) };
return None;
}
// Pre-signalled: the first wait on a fresh slot must return immediately.
let fci = vk::FenceCreateInfo {
s_type: vk::StructureType::FENCE_CREATE_INFO,
p_next: std::ptr::null(),
flags: vk::FenceCreateFlags::SIGNALED,
_marker: std::marker::PhantomData,
};
let mut slots = Vec::with_capacity(CAPTURE_SLOTS);
for (i, &command_buffer) in cbs.iter().enumerate() {
let Some((image, memory)) = (unsafe { allocate_dmabuf_image(ds, w, h, f, "capture") })
else {
unsafe { destroy_partial_ring(ds, command_pool, slots) };
return None;
};
let mut fence = vk::Fence::null();
if unsafe { (ds.fp.create_fence)(ds.raw, &fci, std::ptr::null(), &mut fence) }
!= vk::Result::SUCCESS
{
unsafe { (ds.fp.destroy_image)(ds.raw, image, std::ptr::null()) };
unsafe { (ds.fp.free_memory)(ds.raw, memory, std::ptr::null()) };
unsafe { destroy_partial_ring(ds, command_pool, slots) };
return None;
}
let stride = unsafe { query_stride(ds, image) };
// Export once. Each frame hands the encoder a dup of this fd, which
// costs a file-descriptor clone instead of a kernel export per frame.
let dmabuf_fd = unsafe { get_dmabuf_fd(ds, memory) }.unwrap_or(-1);
if dmabuf_fd < 0 {
log::warn!("capture slot {i}: no DMA-BUF export, falling back to CPU readback");
}
slots.push(CaptureSlot {
image,
memory,
dmabuf_fd,
stride,
command_buffer,
fence,
});
}
log::info!(
"capture ring: {CAPTURE_SLOTS} slots of {w}x{h} fmt={} on queue family {queue_family}",
f.as_raw()
);
Some(CaptureRing {
command_pool,
slots,
size: (w, h, f),
queue_family,
present_wait: Vec::new(),
retired: Vec::new(),
})
}
unsafe fn destroy_partial_ring(
ds: &crate::state::DeviceState,
command_pool: vk::CommandPool,
slots: Vec<CaptureSlot>,
) {
unsafe {
for slot in slots {
(ds.fp.destroy_fence)(ds.raw, slot.fence, std::ptr::null());
(ds.fp.destroy_image)(ds.raw, slot.image, std::ptr::null());
(ds.fp.free_memory)(ds.raw, slot.memory, std::ptr::null());
if slot.dmabuf_fd >= 0 {
libc::close(slot.dmabuf_fd);
}
}
(ds.fp.destroy_command_pool)(ds.raw, command_pool, std::ptr::null());
}
}
/// Tear a ring down. The caller must have established that no slot is in
/// flight; the fence wait here only covers work already submitted.
pub unsafe fn destroy_capture_ring(ds: &crate::state::DeviceState, ring: CaptureRing) {
let fences: Vec<vk::Fence> = ring.slots.iter().map(|s| s.fence).collect();
if !fences.is_empty() {
unsafe {
let _ = (ds.fp.wait_for_fences)(
ds.raw,
fences.len() as u32,
fences.as_ptr(),
vk::TRUE,
5_000_000_000,
);
}
}
unsafe {
if let Some(destroy) = ds.fp.destroy_semaphore {
for &sem in ring.present_wait.iter().chain(ring.retired.iter()) {
if sem != vk::Semaphore::null() {
destroy(ds.raw, sem, std::ptr::null());
}
}
}
}
unsafe { destroy_partial_ring(ds, ring.command_pool, ring.slots) };
}
// ── HUDless command injection ─────────────────────────────────────────────────
pub unsafe fn inject_hudless_copy(cb: vk::CommandBuffer, dk: usize) {
@@ -451,57 +596,85 @@ pub unsafe fn inject_hudless_copy(cb: vk::CommandBuffer, dk: usize) {
}
}
// ── Final frame GPU blit (swapchain → final_image) ────────────────────────────
// ── Final frame GPU blit (swapchain → capture slot) ──────────────────────────
pub unsafe fn capture_final_frame(
/// What a successful capture hands back to the present hook.
pub struct CaptureSubmission {
/// Keeps the ring slot reserved until the encoder is finished with it.
pub slot: crate::slots::SlotGuard,
/// The semaphore the present must now wait on. The application's own wait
/// semaphores were consumed by the blit submission, so presenting on them
/// again would be a double wait.
pub present_wait: vk::Semaphore,
}
/// Blit the presented swapchain image into a ring slot, ahead of the present.
///
/// Two orderings have to hold and neither did before.
///
/// The blit must not read the swapchain image before the game has finished
/// rendering into it. The game signals that with the semaphores it attached to
/// `VkPresentInfoKHR`, so the blit waits on exactly those.
///
/// The game must not render into that image again before the blit has read it.
/// Presentation is what releases the image back to the application, so the
/// present is made to wait on a semaphore the blit signals. The old code
/// submitted the blit from a worker thread after `vkQueuePresentKHR` had
/// already returned, which guaranteed neither.
///
/// Returns `None` when the frame cannot be captured, in which case the caller
/// must present unmodified — the application's semaphores have not been touched.
pub unsafe fn capture_present_frame(
ds: &crate::state::DeviceState,
queue: vk::Queue,
si: vk::Image,
fmt: vk::Format,
ext: vk::Extent2D,
_frame: u64,
) {
image_index: usize,
app_waits: &[vk::Semaphore],
) -> Option<CaptureSubmission> {
if ext.width == 0 || ext.height == 0 {
return;
return None;
}
if !ds
.swapchain_transfer_src
.load(std::sync::atomic::Ordering::Relaxed)
{
return None;
}
unsafe { ensure_final_image(ds, ext.width, ext.height, fmt) };
let fi = match *ds.final_image.lock().unwrap() {
Some(i) => i,
None => return,
};
// ── Lazy-init reusable capture resources ──────────────────────
let mut res_guard = ds.capture_resources.lock().unwrap();
let res = match res_guard.as_mut() {
Some(r) => r,
None => match unsafe { create_capture_resources(ds) } {
Some(r) => {
*res_guard = Some(r);
res_guard.as_mut().unwrap()
}
None => return,
},
};
// A command buffer may only be submitted to the family its pool was created
// for. An unknown queue means one the layer never saw through
// vkGetDeviceQueue, so there is nothing safe to assume about it.
let queue_family = *crate::state::QUEUE_TO_FAMILY.get(&queue.as_raw())?;
let idx = res.current;
let cb = res.command_buffers[idx];
let fence = res.fences[idx];
let mut ring_guard = ds.capture_ring.lock().ok()?;
if !unsafe {
ensure_capture_ring(ds, &mut ring_guard, ext.width, ext.height, fmt, queue_family)
} {
return None;
}
let ring = ring_guard.as_mut()?;
// Wait for THIS slot's previous use to finish (not the other slot).
// Use a short timeout — if the GPU is busy with game rendering, skip
// this capture instead of stalling the game's render loop.
let present_wait = unsafe { ensure_present_semaphore(ds, ring, image_index) }?;
// Never blocks: a frame with no free slot is one the encoder has not caught
// up with, and stalling the game's present to wait for it would be worse
// than skipping it.
let guard = ds.capture_slots.try_acquire()?;
let slot = ring.slots.get(guard.index())?;
let cb = slot.command_buffer;
let fence = slot.fence;
let fi = slot.image;
// A free slot's fence is already signalled — the capture worker waits on it
// before the encoder ever sees the frame. This covers the paths that
// abandon a frame and return the slot without that wait.
unsafe {
let result = (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000); // 1ms timeout
if result != vk::Result::SUCCESS {
// GPU not ready — skip this capture, try next slot
res.current = (idx + 1) % 4;
return;
if (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 2_000_000) != vk::Result::SUCCESS {
return None;
}
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
}
// Reset and re-record
unsafe {
let _ = (ds.fp.reset_command_buffer)(cb, vk::CommandBufferResetFlags::empty());
}
@@ -513,7 +686,7 @@ pub unsafe fn capture_final_frame(
_marker: std::marker::PhantomData,
};
if unsafe { (ds.fp.begin_command_buffer)(cb, &bi) } != vk::Result::SUCCESS {
return;
return None;
}
let b1 = image_barrier!(
@@ -591,7 +764,7 @@ pub unsafe fn capture_final_frame(
(ds.fp.cmd_pipeline_barrier)(
cb,
vk::PipelineStageFlags::TRANSFER,
vk::PipelineStageFlags::TOP_OF_PIPE,
vk::PipelineStageFlags::BOTTOM_OF_PIPE,
vk::DependencyFlags::empty(),
0,
std::ptr::null(),
@@ -603,89 +776,115 @@ pub unsafe fn capture_final_frame(
}
if unsafe { (ds.fp.end_command_buffer)(cb) } != vk::Result::SUCCESS {
return;
return None;
}
let wait_stages = vec![vk::PipelineStageFlags::TRANSFER; app_waits.len()];
let subi = vk::SubmitInfo {
s_type: vk::StructureType::SUBMIT_INFO,
p_next: std::ptr::null(),
wait_semaphore_count: 0,
p_wait_semaphores: std::ptr::null(),
p_wait_dst_stage_mask: std::ptr::null(),
wait_semaphore_count: app_waits.len() as u32,
p_wait_semaphores: if app_waits.is_empty() {
std::ptr::null()
} else {
app_waits.as_ptr()
},
p_wait_dst_stage_mask: if wait_stages.is_empty() {
std::ptr::null()
} else {
wait_stages.as_ptr()
},
command_buffer_count: 1,
p_command_buffers: &cb,
signal_semaphore_count: 0,
p_signal_semaphores: std::ptr::null(),
signal_semaphore_count: 1,
p_signal_semaphores: &present_wait,
_marker: std::marker::PhantomData,
};
unsafe {
if (ds.fp.queue_submit)(queue, 1, &subi, fence) != vk::Result::SUCCESS {
log::warn!("capture queue_submit failed — frame skipped");
// The submit never happened, so nothing waited on the application's
// semaphores and nothing will signal ours. Re-signal the fence by
// hand so the slot is reusable, and let the caller present as the
// application intended.
let _ = (ds.fp.reset_fences)(ds.raw, 1, &fence);
return;
}
}
// Toggle to the next slot
res.current = (idx + 1) % 4;
}
unsafe fn create_capture_resources(ds: &crate::state::DeviceState) -> Option<CaptureResources> {
let pci = vk::CommandPoolCreateInfo {
s_type: vk::StructureType::COMMAND_POOL_CREATE_INFO,
p_next: std::ptr::null(),
flags: vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER, // allow per-cb reset
queue_family_index: 0,
_marker: std::marker::PhantomData,
};
let mut cp = vk::CommandPool::null();
if unsafe { (ds.fp.create_command_pool)(ds.raw, &pci, std::ptr::null(), &mut cp) }
!= vk::Result::SUCCESS
{
return None;
}
let ai = vk::CommandBufferAllocateInfo {
s_type: vk::StructureType::COMMAND_BUFFER_ALLOCATE_INFO,
p_next: std::ptr::null(),
command_pool: cp,
level: vk::CommandBufferLevel::PRIMARY,
command_buffer_count: 4,
_marker: std::marker::PhantomData,
};
let mut cbs = [vk::CommandBuffer::null(); 4];
if unsafe { (ds.fp.allocate_command_buffers)(ds.raw, &ai, cbs.as_mut_ptr()) }
!= vk::Result::SUCCESS
{
unsafe { (ds.fp.destroy_command_pool)(ds.raw, cp, std::ptr::null()) };
return None;
}
// Create fences PRE-SIGNALED so the first wait_for_fences returns immediately
let fci = vk::FenceCreateInfo {
s_type: vk::StructureType::FENCE_CREATE_INFO,
p_next: std::ptr::null(),
flags: vk::FenceCreateFlags::SIGNALED,
_marker: std::marker::PhantomData,
};
let mut fences = [vk::Fence::null(); 4];
for f in &mut fences {
if unsafe { (ds.fp.create_fence)(ds.raw, &fci, std::ptr::null(), f) } != vk::Result::SUCCESS
{
unsafe { (ds.fp.destroy_command_pool)(ds.raw, cp, std::ptr::null()) };
return None;
}
}
Some(CaptureResources {
command_pool: cp,
command_buffers: cbs,
fences,
current: 0,
Some(CaptureSubmission {
slot: guard,
present_wait,
})
}
/// Set aside the semaphore a failed present was given.
///
/// A present that returns an error may or may not have waited on it, so it can
/// neither be signalled again nor destroyed while it might still be pending.
/// The next capture for that image index creates a fresh one.
pub fn retire_present_semaphore(ds: &crate::state::DeviceState, image_index: usize) {
let Ok(mut ring_guard) = ds.capture_ring.lock() else {
return;
};
let Some(ring) = ring_guard.as_mut() else {
return;
};
if let Some(slot) = ring.present_wait.get_mut(image_index) {
let sem = std::mem::replace(slot, vk::Semaphore::null());
if sem != vk::Semaphore::null() {
ring.retired.push(sem);
}
}
}
/// Set aside every per-image semaphore, for a swapchain that is going away.
pub fn retire_all_present_semaphores(ds: &crate::state::DeviceState) {
let Ok(mut ring_guard) = ds.capture_ring.lock() else {
return;
};
let Some(ring) = ring_guard.as_mut() else {
return;
};
for sem in std::mem::take(&mut ring.present_wait) {
if sem != vk::Semaphore::null() {
ring.retired.push(sem);
}
}
}
/// The semaphore for `image_index`, created on first use.
///
/// One per swapchain image rather than one per ring slot: a binary semaphore
/// cannot be signalled again until its previous wait has completed, and the
/// application reacquiring the image is the only evidence of that available
/// from inside the layer.
unsafe fn ensure_present_semaphore(
ds: &crate::state::DeviceState,
ring: &mut CaptureRing,
image_index: usize,
) -> Option<vk::Semaphore> {
let create = ds.fp.create_semaphore?;
if ring.present_wait.len() <= image_index {
ring.present_wait.resize(image_index + 1, vk::Semaphore::null());
}
if ring.present_wait[image_index] == vk::Semaphore::null() {
let ci = vk::SemaphoreCreateInfo {
s_type: vk::StructureType::SEMAPHORE_CREATE_INFO,
p_next: std::ptr::null(),
flags: vk::SemaphoreCreateFlags::empty(),
_marker: std::marker::PhantomData,
};
let mut sem = vk::Semaphore::null();
if unsafe { create(ds.raw, &ci, std::ptr::null(), &mut sem) } != vk::Result::SUCCESS {
return None;
}
ring.present_wait[image_index] = sem;
}
Some(ring.present_wait[image_index])
}
// ── CPU pixel readback (fallback when DMA-BUF unavailable) ───────────────────
pub unsafe fn read_frame_pixels(

View File

@@ -228,18 +228,6 @@ pub unsafe extern "system" fn vkCmdEndRenderPass(command_buffer: vk::CommandBuff
None => return,
};
// Phase 4: track the largest extent we've seen (main framebuffer)
let current_extent = CB_STATE
.get(&cb_key)
.map(|r| r.current_image_extent)
.unwrap_or(None);
if let Some(ext) = current_extent {
let mut largest = ds.largest_extent.lock().unwrap();
if ext.width * ext.height > largest.width * largest.height {
*largest = ext;
}
}
// Phase 4: check if we need to inject a HUDless capture copy (device-level)
let needs_hudless_capture = ds
.pending_capture_frame

View File

@@ -12,7 +12,6 @@ use ash::vk::{self, Handle};
use dashmap::DashMap;
use std::os::raw::c_void;
use std::sync::Arc;
use std::sync::atomic::Ordering;
const VK_LAYER_LINK_INFO: u32 = 0;
@@ -95,32 +94,17 @@ pub unsafe extern "system" fn vkCreateDevice(
}
}
// ── Bump queue count for dedicated capture queue ──────────────────
// Add one extra queue to the first queue family so capture
// submissions don't compete with game rendering.
let mut capture_queue_index = 0u32;
let queue_infos: Vec<vk::DeviceQueueCreateInfo> = if ci.queue_create_info_count > 0
&& !ci.p_queue_create_infos.is_null()
{
let slice = unsafe {
std::slice::from_raw_parts(ci.p_queue_create_infos, ci.queue_create_info_count as usize)
};
let mut qis = slice.to_vec();
if let Some(first) = qis.first_mut() {
capture_queue_index = first.queue_count; // use the NEXT index
first.queue_count += 1;
}
qis
} else {
Vec::new()
};
// Try with injected extensions first.
//
// The device's queue create info is passed through unchanged. An earlier
// version bumped the first family's queue count by one to get a dedicated
// capture queue, which was then never used — and could not be: the capture
// blit has to be submitted to the queue the game presents on, or it gains
// no ordering against the present. All the bump did was risk exceeding the
// family's available queue count on the way in.
let mut modified_ci = *ci;
modified_ci.enabled_extension_count = extended.len() as u32;
modified_ci.pp_enabled_extension_names = extended.as_ptr();
modified_ci.queue_create_info_count = queue_infos.len() as u32;
modified_ci.p_queue_create_infos = queue_infos.as_ptr();
let mut dmabuf_available = true;
let result =
@@ -150,8 +134,6 @@ pub unsafe extern "system" fn vkCreateDevice(
let device = unsafe { *p_device };
// Cache physical device memory properties
let mut mem_props = vk::PhysicalDeviceMemoryProperties::default();
unsafe { (istate.get_physical_device_memory_properties)(physical_device, &mut mem_props) };
macro_rules! load {
($name:literal) => {
@@ -206,6 +188,8 @@ pub unsafe extern "system" fn vkCreateDevice(
// Phase 4 — synchronisation
create_fence: load!(b"vkCreateFence\0"),
create_semaphore: try_load!(b"vkCreateSemaphore\0"),
destroy_semaphore: try_load!(b"vkDestroySemaphore\0"),
destroy_fence: load!(b"vkDestroyFence\0"),
create_command_pool: load!(b"vkCreateCommandPool\0"),
destroy_command_pool: load!(b"vkDestroyCommandPool\0"),
@@ -231,28 +215,6 @@ pub unsafe extern "system" fn vkCreateDevice(
cmd_draw_indexed_indirect_count: try_load!(b"vkCmdDrawIndexedIndirectCount\0"),
};
// ── Retrieve capture queue from the bumped slot ───────────────────
let mut capture_queue = vk::Queue::null();
if queue_infos
.first()
.map(|q| q.queue_count > 1)
.unwrap_or(false)
{
let qi = &queue_infos[0];
unsafe {
(fp.get_device_queue)(
device,
qi.queue_family_index,
capture_queue_index,
&mut capture_queue,
);
}
log::info!(
"capture queue: family={} index={capture_queue_index}",
qi.queue_family_index
);
}
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
// Phase 3: load shader hash config
@@ -290,23 +252,18 @@ pub unsafe extern "system" fn vkCreateDevice(
hudless_image: std::sync::Mutex::new(None),
hudless_memory: std::sync::Mutex::new(None),
hudless_size: std::sync::Mutex::new((0, 0, vk::Format::UNDEFINED)),
final_image: std::sync::Mutex::new(None),
final_memory: std::sync::Mutex::new(None),
final_size: std::sync::Mutex::new((0, 0, vk::Format::UNDEFINED)),
final_stride: std::sync::atomic::AtomicU32::new(0),
capture_ring: std::sync::Mutex::new(None),
capture_slots: crate::slots::SlotPool::new(crate::state::CAPTURE_SLOTS),
swapchain: std::sync::Mutex::new(None),
swapchain_images: std::sync::Mutex::new(Vec::new()),
swapchain_format: std::sync::Mutex::new(vk::Format::UNDEFINED),
swapchain_transfer_src: std::sync::atomic::AtomicBool::new(false),
swapchain_extent: std::sync::Mutex::new(vk::Extent2D {
width: 0,
height: 0,
}),
swapchain_colorspace: std::sync::atomic::AtomicU32::new(0),
frame_counter: std::sync::atomic::AtomicU64::new(0),
largest_extent: std::sync::Mutex::new(vk::Extent2D {
width: 0,
height: 0,
}),
hud_detected_frame: std::sync::atomic::AtomicBool::new(false),
pending_capture_frame: std::sync::atomic::AtomicBool::new(false),
@@ -315,24 +272,8 @@ pub unsafe extern "system" fn vkCreateDevice(
encoder: std::sync::Mutex::new(None),
capture_resources: std::sync::Mutex::new(None),
capture_queue: std::sync::Mutex::new(capture_queue),
fake_images: std::sync::Mutex::new(Vec::new()),
fake_memories: std::sync::Mutex::new(Vec::new()),
fake_fds: std::sync::Mutex::new(Vec::new()),
fake_strides: std::sync::Mutex::new(Vec::new()),
fake_available: std::sync::Mutex::new(Vec::new()),
fake_image_count: std::sync::atomic::AtomicU32::new(0),
fake_swapchain: std::sync::Mutex::new(None),
signal_queue: std::sync::Mutex::new(vk::Queue::null()),
next_acquire: std::sync::atomic::AtomicU32::new(0),
memory_properties: std::sync::Mutex::new(mem_props),
acquire_dummy_pool: std::sync::Mutex::new(vk::CommandPool::null()),
acquire_dummy_cb: std::sync::Mutex::new(vk::CommandBuffer::null()),
cached_dmabuf_fd: std::sync::atomic::AtomicI32::new(-1),
target_fps: std::sync::atomic::AtomicU32::new(0),
last_capture_time: std::sync::Mutex::new(None),
frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()),
capture_tx: std::sync::Mutex::new(None),
});
@@ -377,42 +318,26 @@ pub unsafe extern "system" fn vkDestroyDevice(
// Brief yield to let threads notice the disconnect.
std::thread::sleep(std::time::Duration::from_millis(50));
// ── 2. Clean up capture resources (double-buffered cmd pool + fences)
// ── 2. Tear down the capture ring ────────────────────────────────────
//
// Images, memory, fences, exported fds and the per-swapchain-image
// semaphores all belong to the ring now, so one teardown covers what used
// to be three separate steps.
{
let mut res_guard = ds.capture_resources.lock().unwrap();
if let Some(res) = res_guard.take() {
unsafe {
// Wait for any in-flight capture commands to finish before
// destroying the fences / command pool.
let _ = (ds.fp.wait_for_fences)(
ds.raw,
res.fences.len() as u32,
res.fences.as_ptr(),
vk::TRUE,
5_000_000_000, // 5 seconds — should be instant
let ring = ds.capture_ring.lock().unwrap().take();
if let Some(ring) = ring {
if !ds.capture_slots.all_free() {
log::warn!(
"device destroyed with {} capture slot(s) still in flight",
crate::state::CAPTURE_SLOTS - ds.capture_slots.available()
);
for &f in &res.fences {
(ds.fp.destroy_fence)(ds.raw, f, std::ptr::null());
}
(ds.fp.destroy_command_pool)(ds.raw, res.command_pool, std::ptr::null());
}
log::debug!("capture resources destroyed");
unsafe { crate::capture::destroy_capture_ring(&ds, ring) };
log::debug!("capture ring destroyed");
}
}
// ── 3. Free final_image / final_memory ────────────────────────────────
{
let img = ds.final_image.lock().unwrap().take();
let mem = ds.final_memory.lock().unwrap().take();
if let Some(i) = img {
unsafe { (ds.fp.destroy_image)(ds.raw, i, std::ptr::null()) };
}
if let Some(m) = mem {
unsafe { (ds.fp.free_memory)(ds.raw, m, std::ptr::null()) };
}
}
// ── 4. Free hudless_image / hudless_memory ────────────────────────────
// ── 3. Free hudless_image / hudless_memory ────────────────────────────
{
let img = ds.hudless_image.lock().unwrap().take();
let mem = ds.hudless_memory.lock().unwrap().take();
@@ -424,19 +349,18 @@ pub unsafe extern "system" fn vkDestroyDevice(
}
}
// ── 5. Close cached DMA-BUF fd ────────────────────────────────────────
{
let fd = ds.cached_dmabuf_fd.load(Ordering::Relaxed);
if fd >= 0 {
unsafe { libc::close(fd) };
log::debug!("cached DMA-BUF fd {} closed", fd);
}
// ── 4. Clean up queue → device key mappings for this device ───────────
let stale: Vec<u64> = QUEUE_TO_DEVICE_KEY
.iter()
.filter(|e| *e.value() == key)
.map(|e| *e.key())
.collect();
for q in stale {
crate::state::QUEUE_TO_FAMILY.remove(&q);
}
// ── 6. Clean up queue → device key mappings for this device ───────────
QUEUE_TO_DEVICE_KEY.retain(|_, dk| *dk != key);
// ── 7. Call the real vkDestroyDevice ──────────────────────────────────
// ── 5. Call the real vkDestroyDevice ──────────────────────────────────
unsafe { (ds.fp.destroy_device)(device, p_allocator) };
log::info!("vkDestroyDevice complete");
@@ -454,11 +378,7 @@ pub unsafe extern "system" fn vkGetDeviceQueue(
unsafe { (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) };
let queue = unsafe { *p_queue };
QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key);
// Store first queue for acquire semaphore signaling
let mut sq = ds.signal_queue.lock().unwrap();
if *sq == vk::Queue::null() {
*sq = queue;
}
crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), queue_family_index);
}
}

View File

@@ -209,6 +209,16 @@ pub type PFN_vkCreateFence = unsafe extern "system" fn(
pub type PFN_vkDestroyFence =
unsafe extern "system" fn(vk::Device, vk::Fence, *const vk::AllocationCallbacks);
pub type PFN_vkCreateSemaphore = unsafe extern "system" fn(
vk::Device,
*const vk::SemaphoreCreateInfo,
*const vk::AllocationCallbacks,
*mut vk::Semaphore,
) -> vk::Result;
pub type PFN_vkDestroySemaphore =
unsafe extern "system" fn(vk::Device, vk::Semaphore, *const vk::AllocationCallbacks);
pub type PFN_vkCreateCommandPool = unsafe extern "system" fn(
vk::Device,
*const vk::CommandPoolCreateInfo,
@@ -337,6 +347,10 @@ pub struct NextDeviceFn {
// Phase 4 — synchronisation
pub create_fence: PFN_vkCreateFence,
pub destroy_fence: PFN_vkDestroyFence,
/// Core since 1.0, but loaded optionally so a driver that somehow fails to
/// resolve it degrades to no capture rather than to a null-pointer call.
pub create_semaphore: Option<PFN_vkCreateSemaphore>,
pub destroy_semaphore: Option<PFN_vkDestroySemaphore>,
pub create_command_pool: PFN_vkCreateCommandPool,
pub destroy_command_pool: PFN_vkDestroyCommandPool,
pub reset_command_pool: PFN_vkResetCommandPool,

View File

@@ -65,7 +65,6 @@ const VK_COLOR_SPACE_HDR10_ST2084_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::
const VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT: u32 =
colorspace(ash::vk::ColorSpaceKHR::EXTENDED_SRGB_LINEAR_EXT);
const VK_COLOR_SPACE_BT2020_LINEAR_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::BT2020_LINEAR_EXT);
const VK_COLOR_SPACE_DOLBYVISION_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::DOLBYVISION_EXT);
const VK_COLOR_SPACE_HDR10_HLG_EXT: u32 = colorspace(ash::vk::ColorSpaceKHR::HDR10_HLG_EXT);
/// The converter input format for a swapchain's `VkFormat`, or `None` when
@@ -94,9 +93,7 @@ pub fn vk_format_to_input_format(vk_format: u32) -> Option<InputFormat> {
pub fn vk_colorspace_to_color_space(vk_colorspace: u32) -> ColorSpace {
match vk_colorspace {
VK_COLOR_SPACE_HDR10_ST2084_EXT
| VK_COLOR_SPACE_DOLBYVISION_EXT
| VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpace::Bt2020,
VK_COLOR_SPACE_HDR10_ST2084_EXT | VK_COLOR_SPACE_HDR10_HLG_EXT => ColorSpace::Bt2020,
// Both are linear, so the inverse sRGB EOTF that `SrgbToBt2020Pq` applies
// would decode data that was never encoded. `Bt709LinearToBt2020Pq` is
@@ -180,6 +177,19 @@ pub struct CapturedFrame {
pub height: u32,
pub vk_format: u32,
pub vk_colorspace: u32,
/// When the game presented this frame. Carried all the way to the wire so
/// the timestamp describes the frame rather than the encoder's backlog.
pub present_time: Instant,
/// Reserves the capture ring slot this frame's DMA-BUF lives in. Dropping
/// the frame — encoded, skipped, or abandoned — returns the slot, so the
/// present hook can never blit over a buffer the encoder is still reading.
pub slot: Option<crate::slots::SlotGuard>,
}
/// An encode in flight, with the time of the present it came from.
struct EncodedFrame {
future: EncodeFuture,
present_time: Instant,
}
pub enum FrameSource {
@@ -353,7 +363,7 @@ impl PipelineHandle {
.ok_or_else(|| "no hardware video encoder found on this GPU".to_string())?;
let (frame_tx, frame_rx) = mpsc::sync_channel::<CapturedFrame>(2);
let (encoded_tx, encoded_rx) = mpsc::sync_channel::<EncodeFuture>(2);
let (encoded_tx, encoded_rx) = mpsc::sync_channel::<EncodedFrame>(2);
let (reconfig_tx, reconfig_rx) = mpsc::channel::<EncodeSettingsChange>();
let shutdown = Arc::new(AtomicBool::new(false));
let idr_requested = Arc::new(AtomicBool::new(false));
@@ -399,6 +409,8 @@ impl PipelineHandle {
width: config.width as u16,
height: config.height as u16,
encode_ms: encode_avg_ms.clone(),
idr_requested: idr_requested.clone(),
epoch: Instant::now(),
};
thread::Builder::new()
.name("nescapture-ipc".into())
@@ -596,7 +608,7 @@ pub struct EncodeSettingsChange {
fn encoder_thread(
mut cfg: EncoderConfig,
frame_rx: mpsc::Receiver<CapturedFrame>,
encoded_tx: mpsc::SyncSender<EncodeFuture>,
encoded_tx: mpsc::SyncSender<EncodedFrame>,
shutdown: Arc<AtomicBool>,
) {
let ctx = cfg.ctx;
@@ -738,6 +750,12 @@ fn encoder_thread(
state.encoder.request_idr();
}
// Each ring slot is a distinct DMA-BUF, so the importer caches an
// imported image per slot. Importing every frame under index 0 would
// have handed the encoder whichever buffer happened to be imported
// first, for every frame after it.
let buffer_index = raw.slot.as_ref().map(|s| s.index()).unwrap_or(0);
let result = match &mut raw.source {
FrameSource::DmaBuf {
fd,
@@ -758,6 +776,7 @@ fn encoder_thread(
raw.height,
raw.vk_format,
frame_number,
buffer_index,
),
None => {
unsafe { libc::close(owned_fd) };
@@ -782,7 +801,21 @@ fn encoder_thread(
match result {
Err(e) => log::warn!("encode frame {frame_number}: {e}"),
Ok(future) => {
let _ = encoded_tx.try_send(future);
// Blocking, deliberately. Dropping an encoded frame does not
// just waste the encode — it breaks the reference chain. The
// encoder's DPB believes the frame exists and codes later
// frames against it, so a decoder that never receives it shows
// corruption until the next IDR. Blocking here pushes back
// through `frame_rx` to `push_frame`, where a drop is free:
// that frame never entered the encoder and no later frame
// refers to it.
let pending = EncodedFrame {
future,
present_time: raw.present_time,
};
if encoded_tx.send(pending).is_err() {
break;
}
}
}
@@ -907,6 +940,7 @@ fn gpu_encode_frame(
height: u32,
vk_format: u32,
frame_number: u32,
buffer_index: usize,
) -> Result<EncodeFuture> {
use ash::vk;
@@ -920,7 +954,7 @@ fn gpu_encode_frame(
};
let (imported_image, needs_layout_transition) = importer
.import_or_reuse(0, width, height, bgra_vk_fmt, &[plane])
.import_or_reuse(buffer_index, width, height, bgra_vk_fmt, &[plane])
.map_err(|e| anyhow::anyhow!("DmaBufImporter: {e}"))?;
unsafe { libc::close(fd) };
@@ -1042,11 +1076,15 @@ struct IpcConfig {
width: u16,
height: u16,
encode_ms: Arc<AtomicU32>,
/// Shared with the encoder thread, which honours it on the next frame.
idr_requested: Arc<AtomicBool>,
/// Zero point for wire timestamps.
epoch: Instant,
}
fn ipc_send_thread(
cfg: IpcConfig,
encoded_rx: mpsc::Receiver<EncodeFuture>,
encoded_rx: mpsc::Receiver<EncodedFrame>,
shutdown: Arc<AtomicBool>,
) {
let socket = match UnixDatagram::unbound() {
@@ -1069,7 +1107,9 @@ fn ipc_send_thread(
}
};
let start_time = Instant::now();
// Fixed before the first frame arrives, so every timestamp shares an epoch
// even though frames are stamped from their own present.
let start_time = cfg.epoch;
let mut frame_count: u64 = 0;
'outer: loop {
@@ -1105,11 +1145,12 @@ fn ipc_send_thread(
if shutdown.load(Ordering::Relaxed) {
break 'outer;
}
let result = match encoded_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(p) => pollster::block_on(p),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break 'outer,
};
let (result, present_time) =
match encoded_rx.recv_timeout(std::time::Duration::from_millis(100)) {
Ok(p) => (pollster::block_on(p.future), p.present_time),
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break 'outer,
};
let pkt = match result {
Ok(p) => p,
Err(e) => {
@@ -1124,7 +1165,13 @@ fn ipc_send_thread(
cfg.encode_ms.store(enc_ms.to_bits(), Ordering::Relaxed);
}
let timestamp_ms = start_time.elapsed().as_millis() as u32;
// From the present, not from here. Stamping at send time folded
// however long the frame spent queued for the encoder into the
// timestamp, so the receiver could not tell capture time from
// backlog and had nothing honest to pace on.
let timestamp_ms = present_time
.saturating_duration_since(start_time)
.as_millis() as u32;
let mut flags = if pkt.is_key_frame { FLAG_KEYFRAME } else { 0 };
// Set FLAG_RECONFIG on the first frame after an encoder reconfig.
// Clear it after setting so only the first frame is marked.
@@ -1149,7 +1196,11 @@ fn ipc_send_thread(
log::warn!("IPC send failed ({} frames dropped): {e}", error_count);
last_warn = Instant::now();
}
// Socket disconnected — reconnect
// Socket disconnected — reconnect. The frames lost while it
// was down are gone from the reference chain, so ask for an
// IDR rather than resuming into a stream the receiver cannot
// reconstruct.
cfg.idr_requested.store(true, Ordering::Relaxed);
log::warn!("IPC disconnected, reconnecting...");
break;
}
@@ -1284,7 +1335,7 @@ mod tests {
#[test]
fn hdr_colour_spaces_select_the_hdr_arm() {
for cs in [Cs::HDR10_ST2084_EXT, Cs::DOLBYVISION_EXT, Cs::HDR10_HLG_EXT] {
for cs in [Cs::HDR10_ST2084_EXT, Cs::HDR10_HLG_EXT] {
let raw = cs.as_raw() as u32;
assert_eq!(
vk_colorspace_to_color_space(raw),
@@ -1336,7 +1387,6 @@ mod tests {
Cs::SRGB_NONLINEAR,
Cs::PASS_THROUGH_EXT,
Cs::HDR10_ST2084_EXT,
Cs::DOLBYVISION_EXT,
Cs::HDR10_HLG_EXT,
Cs::EXTENDED_SRGB_LINEAR_EXT,
Cs::BT2020_LINEAR_EXT,
@@ -1409,7 +1459,6 @@ mod tests {
for cs in [
Cs::SRGB_NONLINEAR,
Cs::HDR10_ST2084_EXT,
Cs::DOLBYVISION_EXT,
Cs::HDR10_HLG_EXT,
Cs::EXTENDED_SRGB_LINEAR_EXT,
Cs::PASS_THROUGH_EXT,

View File

@@ -31,9 +31,11 @@ mod dmabuf_import;
mod encode;
mod framebuffer;
mod instance;
mod pacing;
mod pipeline;
mod present;
mod shader;
mod slots;
mod state;
mod swapchain;

View File

@@ -0,0 +1,172 @@
// ─────────────────────────────────────────────────────────────────────────────
// pacing.rs — capture frame-rate gate
//
// The layer is the only place that sees every frame the game produces, so it
// is the only place that can decide which ones are worth capturing. Nothing
// here consults the compositor: no vblank, no surface, no present feedback,
// just the monotonic clock. nescope runs uncapped by design and must stay out
// of this decision.
//
// The gate only ever *drops*. It never waits, never blocks the game's present,
// and never admits more frames than the game offered. A game running below the
// target is passed through untouched.
// ─────────────────────────────────────────────────────────────────────────────
use std::time::{Duration, Instant};
/// Admits at most `target_fps` frames per second, dropping the rest.
///
/// The deadline advances by a fixed interval rather than being recomputed from
/// the admitted frame's arrival time, so a game presenting slightly off-cadence
/// does not accumulate drift.
pub struct FrameGate {
/// Zero means uncapped — every frame is admitted.
interval: Duration,
/// Frames arriving within this much of the deadline are admitted early.
///
/// Without it, a game running at exactly the target rate beats against the
/// gate: presents land a hair before each deadline, get rejected, and the
/// stream loses a frame every time the two rates drift past each other.
slack: Duration,
/// `None` until the first frame establishes the cadence.
next_deadline: Option<Instant>,
}
impl FrameGate {
pub fn new(target_fps: u32) -> Self {
let interval = if target_fps == 0 {
Duration::ZERO
} else {
Duration::from_nanos(1_000_000_000 / u64::from(target_fps))
};
Self {
interval,
slack: interval / 8,
next_deadline: None,
}
}
/// Read the target from the environment. `0` disables the gate.
pub fn from_env() -> Self {
let fps = std::env::var("NESCAPTURE_FPS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(60);
Self::new(fps)
}
/// Frames per second this gate admits, or 0 when uncapped.
pub fn target_fps(&self) -> u32 {
if self.interval.is_zero() {
0
} else {
(1_000_000_000 / self.interval.as_nanos().max(1)) as u32
}
}
/// Decide whether the frame presented at `now` should be captured.
pub fn admit(&mut self, now: Instant) -> bool {
if self.interval.is_zero() {
return true;
}
let Some(deadline) = self.next_deadline else {
self.next_deadline = Some(now + self.interval);
return true;
};
if now + self.slack < deadline {
return false;
}
// Advance one interval from the deadline, not from `now`, so a game
// presenting slightly early or late keeps an exact cadence.
let advanced = deadline + self.interval;
// Slip clamp. If the game stalled — a load screen, a shader compile, a
// hitch — the deadline can end up many intervals in the past. Advancing
// by one interval at a time would leave the gate "owing" frames and it
// would admit a burst of them back to back the moment the game resumes,
// which is exactly when the GPU can least afford it. Drop the debt and
// restart the cadence from now.
self.next_deadline = Some(if advanced <= now {
now + self.interval
} else {
advanced
});
true
}
}
#[cfg(test)]
mod tests {
use super::*;
const HZ60: Duration = Duration::from_nanos(16_666_666);
#[test]
fn uncapped_admits_everything() {
let mut g = FrameGate::new(0);
let t = Instant::now();
for i in 0..100 {
assert!(g.admit(t + Duration::from_micros(i)));
}
}
#[test]
fn fast_game_is_thinned_to_the_target() {
// 300 fps offered, 60 wanted: one frame in five, over a full second.
let mut g = FrameGate::new(60);
let t0 = Instant::now();
let step = Duration::from_nanos(3_333_333);
let admitted = (0..300).filter(|i| g.admit(t0 + step * *i)).count();
assert!((59..=61).contains(&admitted), "admitted {admitted}");
}
#[test]
fn slow_game_passes_through_untouched() {
// 30 fps offered, 60 wanted: nothing may be dropped.
let mut g = FrameGate::new(60);
let t0 = Instant::now();
let step = Duration::from_nanos(33_333_333);
for i in 0..60 {
assert!(g.admit(t0 + step * i), "dropped frame {i} of a 30 fps game");
}
}
#[test]
fn matched_rate_does_not_beat_against_the_gate() {
// A game at exactly the target rate must not lose frames to jitter.
let mut g = FrameGate::new(60);
let t0 = Instant::now();
let mut at = t0;
for i in 0..120 {
// ±1ms of jitter around a perfect 60 Hz cadence.
let jitter = if i % 2 == 0 {
Duration::from_millis(1)
} else {
Duration::ZERO
};
at += HZ60;
assert!(g.admit(at + jitter), "dropped frame {i} of a 60 fps game");
}
}
#[test]
fn a_stall_does_not_bank_a_burst() {
let mut g = FrameGate::new(60);
let t0 = Instant::now();
assert!(g.admit(t0));
// Two seconds of nothing — a load screen.
let resume = t0 + Duration::from_secs(2);
// The first frame back is admitted immediately...
assert!(g.admit(resume));
// ...but the game resuming at 300 fps must not replay the ~120 frames
// the gate "missed" during the stall. Across the next 100ms it may
// admit only what 60 fps allows: about six, not all thirty offered.
let step = Duration::from_nanos(3_333_333);
let burst = (1..30).filter(|i| g.admit(resume + step * *i)).count();
assert!((4..=7).contains(&burst), "admitted {burst} frames in 100ms");
}
}

View File

@@ -1,18 +1,25 @@
use crate::capture;
use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle};
use crate::slots::SlotGuard;
use crate::state::{DEVICE_STATE, QUEUE_TO_DEVICE_KEY};
use ash::vk::{self, Handle};
use std::os::raw::c_void;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
/// A frame already blitted into a ring slot, waiting to be handed to the
/// encoder. The GPU work is submitted before this is queued, so the worker's
/// only job is to wait for it and export the buffer.
pub struct CaptureJob {
pub queue: vk::Queue,
pub sc_image: vk::Image,
pub sc_fmt: vk::Format,
pub sc_ext: vk::Extent2D,
pub frame: u64,
pub ds_key: usize,
/// Holds the ring slot until the encoder is finished with it.
pub slot: SlotGuard,
pub width: u32,
pub height: u32,
pub sc_fmt: vk::Format,
/// When the game handed this frame to `vkQueuePresentKHR`. The only honest
/// capture time — everything downstream is queued behind something.
pub present_time: std::time::Instant,
}
#[unsafe(no_mangle)]
@@ -37,7 +44,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
None => return vk::Result::ERROR_DEVICE_LOST,
};
let frame = ds.frame_counter.fetch_add(1, Ordering::Relaxed);
ds.frame_counter.fetch_add(1, Ordering::Relaxed);
ds.hud_detected_frame.store(false, Ordering::Relaxed);
ds.pending_capture_frame.store(false, Ordering::Relaxed);
ds.capture_injected_frame.store(false, Ordering::Relaxed);
@@ -50,58 +57,155 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
}
let pi = unsafe { &*p_present_info };
if pi.swapchain_count > 0 && !pi.p_swapchains.is_null() && !pi.p_image_indices.is_null() {
let idx = unsafe { *pi.p_image_indices as usize };
let (sc_image, sc_fmt, sc_ext) = {
let images = ds.swapchain_images.lock().unwrap();
let fmt = *ds.swapchain_format.lock().unwrap();
let ext = *ds.swapchain_extent.lock().unwrap();
if idx < images.len() && ext.width > 0 && ext.height > 0 {
(Some(images[idx]), fmt, ext)
} else {
(None, fmt, ext)
}
};
if let Some(sc_image) = sc_image {
// No time-based throttle — let the encoder channel provide natural backpressure
let should = true;
// Rewriting the wait semaphores is only well defined for a single
// swapchain. A multi-swapchain present is rare enough that passing it
// through untouched beats getting the interposition subtly wrong.
let single_swapchain = pi.swapchain_count == 1
&& !pi.p_swapchains.is_null()
&& !pi.p_image_indices.is_null();
if should {
if let Ok(enc) = ds.encoder.lock() {
if let Some(ref h) = *enc {
h.capture_attempts.fetch_add(1, Ordering::Relaxed);
}
}
// Ensure capture worker is running
{
let mut ctx = ds.capture_tx.lock().unwrap();
if ctx.is_none() {
let (tx, rx) = mpsc::channel();
let key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) };
start_capture_worker(key, rx);
*ctx = Some(tx);
}
}
// Queue job to worker thread — don't block present
let job = CaptureJob {
queue,
sc_image,
sc_fmt,
sc_ext,
frame,
ds_key: unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) },
};
if let Ok(capture_tx) = ds.capture_tx.lock() {
let _ = capture_tx.as_ref().unwrap().send(job);
}
}
let submission = if single_swapchain {
unsafe { try_capture(&ds, queue, pi) }
} else {
None
};
let call_down = |info: *const vk::PresentInfoKHR| match ds.fp.queue_present_khr {
Some(f) => unsafe { f(queue, info) },
None => vk::Result::ERROR_EXTENSION_NOT_PRESENT,
};
let Some(submission) = submission else {
return call_down(p_present_info);
};
// The blit consumed the application's wait semaphores, so the present waits
// on ours instead. Presenting on the originals as well would be a second
// wait on an already-consumed signal.
let wait = submission.present_wait;
let rewritten = vk::PresentInfoKHR {
s_type: pi.s_type,
p_next: pi.p_next,
wait_semaphore_count: 1,
p_wait_semaphores: &wait,
swapchain_count: pi.swapchain_count,
p_swapchains: pi.p_swapchains,
p_image_indices: pi.p_image_indices,
p_results: pi.p_results,
_marker: std::marker::PhantomData,
};
let image_index = unsafe { *pi.p_image_indices } as usize;
let result = call_down(&rewritten);
// Only hand the frame on once the present has been accepted. A failed
// present drops the job, which returns the slot.
if matches!(result, vk::Result::SUCCESS | vk::Result::SUBOPTIMAL_KHR) {
queue_for_encode(&ds, submission);
} else {
// The blit signalled this semaphore; whether the failed present waited
// on it is undefined. Set it aside rather than signal it twice.
capture::retire_present_semaphore(&ds, image_index);
}
result
}
/// Everything the present hook needs to carry from the blit to the worker.
struct Submission {
slot: SlotGuard,
present_wait: vk::Semaphore,
width: u32,
height: u32,
sc_fmt: vk::Format,
present_time: std::time::Instant,
}
unsafe fn try_capture(
ds: &crate::state::DeviceState,
queue: vk::Queue,
pi: &vk::PresentInfoKHR,
) -> Option<Submission> {
let image_index = unsafe { *pi.p_image_indices } as usize;
let (sc_image, sc_fmt, sc_ext) = {
let images = ds.swapchain_images.lock().ok()?;
let fmt = *ds.swapchain_format.lock().ok()?;
let ext = *ds.swapchain_extent.lock().ok()?;
if image_index >= images.len() || ext.width == 0 || ext.height == 0 {
return None;
}
(images[image_index], fmt, ext)
};
// Gate before any GPU work is queued. A game presenting faster than the
// target would otherwise pay a full blit and DMA-BUF export for frames the
// encoder throws away moments later.
let present_time = std::time::Instant::now();
let admitted = match ds.frame_gate.lock() {
Ok(mut gate) => gate.admit(present_time),
// A poisoned gate must not stop the stream; capture everything.
Err(_) => true,
};
if !admitted {
return None;
}
if let Ok(enc) = ds.encoder.lock() {
if let Some(ref h) = *enc {
h.capture_attempts.fetch_add(1, Ordering::Relaxed);
}
}
match ds.fp.queue_present_khr {
Some(f) => unsafe { f(queue, p_present_info) },
None => vk::Result::ERROR_EXTENSION_NOT_PRESENT,
let app_waits: &[vk::Semaphore] = if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null()
{
&[]
} else {
unsafe {
std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize)
}
};
let submission = unsafe {
capture::capture_present_frame(ds, queue, sc_image, sc_fmt, sc_ext, image_index, app_waits)
}?;
Some(Submission {
slot: submission.slot,
present_wait: submission.present_wait,
width: sc_ext.width,
height: sc_ext.height,
sc_fmt,
present_time,
})
}
fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) {
let ds_key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) };
{
let mut ctx = match ds.capture_tx.lock() {
Ok(c) => c,
Err(_) => return,
};
if ctx.is_none() {
let (tx, rx) = mpsc::channel();
start_capture_worker(ds_key, rx);
*ctx = Some(tx);
}
if let Some(tx) = ctx.as_ref() {
// Unbounded, but bounded in practice: the ring hands out a fixed
// number of slots and a job holds one for its whole life, so the
// queue can never exceed the slot count.
let _ = tx.send(CaptureJob {
ds_key,
slot: submission.slot,
width: submission.width,
height: submission.height,
sc_fmt: submission.sc_fmt,
present_time: submission.present_time,
});
}
}
}
@@ -110,7 +214,6 @@ pub fn start_capture_worker(ds_key: usize, capture_rx: mpsc::Receiver<CaptureJob
.name("nescapture-capture".into())
.spawn(move || {
while let Ok(job) = capture_rx.recv() {
let t0 = std::time::Instant::now();
let ds = match DEVICE_STATE.get(&job.ds_key) {
Some(s) => s.clone(),
None => {
@@ -118,97 +221,84 @@ pub fn start_capture_worker(ds_key: usize, capture_rx: mpsc::Receiver<CaptureJob
break;
}
};
// Do the blit on the worker's own time
unsafe {
capture::capture_final_frame(
&ds,
job.queue,
job.sc_image,
job.sc_fmt,
job.sc_ext,
job.frame,
)
// Copy the slot's handles out and release the ring lock before
// waiting: the present hook needs that lock every frame and
// must not queue behind a GPU wait.
let Some((fence, dmabuf_fd, stride, image, memory)) = ({
let ring = ds.capture_ring.lock().unwrap();
ring.as_ref()
.and_then(|r| r.slots.get(job.slot.index()))
.map(|s| (s.fence, s.dmabuf_fd, s.stride, s.image, s.memory))
}) else {
continue;
};
// Export DMA-BUF and push to encoder
let source = {
let mem_guard = ds.final_memory.lock().unwrap();
let stride = ds.final_stride.load(Ordering::Relaxed);
if let Some(mem) = *mem_guard {
unsafe { try_make_dmabuf_source(&ds, mem, stride) }.unwrap_or_else(|| {
let (w, h, _) = *ds.final_size.lock().unwrap();
let img = ds.final_image.lock().unwrap().unwrap();
unsafe { capture::read_frame_pixels(&ds, img, mem, w, h) }
.map(FrameSource::Pixels)
.unwrap_or(FrameSource::Pixels(Vec::new()))
})
} else {
// The encoder reads this buffer from pixelforge's own VkDevice,
// which shares no timeline with ours, so the handover has to be
// on the CPU. Waiting here rather than in the present hook is
// the whole point of the worker thread.
let waited = unsafe {
(ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000_000)
};
if waited != vk::Result::SUCCESS {
log::warn!("capture blit did not complete — frame dropped");
continue;
}
let source = if dmabuf_fd >= 0 {
let duped = unsafe { libc::dup(dmabuf_fd) };
if duped < 0 {
log::warn!("dup of capture DMA-BUF failed — frame dropped");
continue;
}
FrameSource::DmaBuf {
fd: duped,
stride,
modifier: 0,
}
} else {
match unsafe {
capture::read_frame_pixels(&ds, image, memory, job.width, job.height)
} {
Some(p) if !p.is_empty() => FrameSource::Pixels(p),
_ => continue,
}
};
let (w, h, _) = *ds.final_size.lock().unwrap();
if !matches!(&source, FrameSource::Pixels(p) if p.is_empty()) {
// Lazy-init encoder
{
let mut enc = ds.encoder.lock().unwrap();
if enc.is_none() {
if let Some(cfg) = PipelineConfig::from_env(w, h) {
ds.target_fps.store(cfg.fps, Ordering::Relaxed);
match PipelineHandle::new(cfg) {
Ok(h) => *enc = Some(h),
Err(e) => panic!("{e}"),
}
// Lazy-init encoder
{
let mut enc = ds.encoder.lock().unwrap();
if enc.is_none() {
if let Some(cfg) = PipelineConfig::from_env(job.width, job.height) {
match PipelineHandle::new(cfg) {
Ok(h) => *enc = Some(h),
Err(e) => panic!("{e}"),
}
}
}
let enc_guard = ds.encoder.lock().unwrap();
if let Some(ref encoder) = *enc_guard {
let capture_elapsed = t0.elapsed().as_secs_f32() * 1000.0;
encoder
.capture_ms
.store(capture_elapsed.to_bits(), Ordering::Relaxed);
encoder.push_frame(CapturedFrame {
source,
width: w,
height: h,
vk_format: job.sc_fmt.as_raw() as u32,
vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed),
});
}
}
let enc_guard = ds.encoder.lock().unwrap();
if let Some(ref encoder) = *enc_guard {
// Measured from the game's present, not from the start of
// this iteration: the wait above is part of what capture
// costs, and timing only the parts after it hid that.
let capture_elapsed = job.present_time.elapsed().as_secs_f32() * 1000.0;
encoder
.capture_ms
.store(capture_elapsed.to_bits(), Ordering::Relaxed);
encoder.push_frame(CapturedFrame {
source,
width: job.width,
height: job.height,
vk_format: job.sc_fmt.as_raw() as u32,
vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed),
present_time: job.present_time,
slot: Some(job.slot),
});
}
}
log::info!("capture worker exiting");
})
.ok();
}
unsafe fn try_make_dmabuf_source(
ds: &crate::state::DeviceState,
mem: vk::DeviceMemory,
stride: u32,
) -> Option<FrameSource> {
let cached = ds.cached_dmabuf_fd.load(Ordering::Relaxed);
let fd = if cached >= 0 {
let duped = unsafe { libc::dup(cached) };
if duped >= 0 {
duped
} else {
let fresh = unsafe { capture::get_dmabuf_fd(ds, mem)? };
ds.cached_dmabuf_fd.store(fresh, Ordering::Relaxed);
unsafe { libc::dup(fresh) }
}
} else {
let fresh = unsafe { capture::get_dmabuf_fd(ds, mem)? };
ds.cached_dmabuf_fd.store(fresh, Ordering::Relaxed);
unsafe { libc::dup(fresh) }
};
if fd < 0 {
return None;
}
Some(FrameSource::DmaBuf {
fd,
stride,
modifier: 0,
})
}

View File

@@ -0,0 +1,100 @@
// ─────────────────────────────────────────────────────────────────────────────
// slots.rs — ownership of the capture ring's destination buffers
//
// A captured frame travels from the present hook, through the capture worker,
// into the encoder thread, and its DMA-BUF must not be written again until the
// encoder has finished reading it. Tracking that by hand across three threads
// is how the single-buffer version got it wrong. Instead the slot index is
// carried by a guard that returns it to the pool when it drops, wherever that
// happens to be — including on the error paths that abandon a frame.
// ─────────────────────────────────────────────────────────────────────────────
use std::sync::{Arc, Mutex};
pub struct SlotPool {
free: Mutex<Vec<usize>>,
count: usize,
}
impl SlotPool {
pub fn new(count: usize) -> Arc<Self> {
Arc::new(Self {
free: Mutex::new((0..count).collect()),
count,
})
}
/// Take a slot, or `None` if every one is still downstream.
///
/// Never blocks. This is called from `vkQueuePresentKHR`, on the game's own
/// thread, where waiting for the encoder to catch up would be a stutter the
/// player can feel. A frame with no slot is simply not captured.
pub fn try_acquire(self: &Arc<Self>) -> Option<SlotGuard> {
let index = self.free.lock().ok()?.pop()?;
Some(SlotGuard {
pool: Arc::clone(self),
index,
})
}
/// How many slots are not currently downstream.
pub fn available(&self) -> usize {
self.free.lock().map(|f| f.len()).unwrap_or(0)
}
/// True when nothing is in flight — the only safe moment to tear the ring
/// down or rebuild it at a new resolution.
pub fn all_free(&self) -> bool {
self.available() == self.count
}
}
pub struct SlotGuard {
pool: Arc<SlotPool>,
index: usize,
}
impl SlotGuard {
pub fn index(&self) -> usize {
self.index
}
}
impl Drop for SlotGuard {
fn drop(&mut self) {
if let Ok(mut free) = self.pool.free.lock() {
free.push(self.index);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pool_hands_out_every_slot_once() {
let pool = SlotPool::new(4);
let held: Vec<_> = (0..4).map(|_| pool.try_acquire().unwrap()).collect();
assert!(pool.try_acquire().is_none(), "handed out a fifth slot");
let mut indices: Vec<_> = held.iter().map(|g| g.index()).collect();
indices.sort_unstable();
assert_eq!(indices, vec![0, 1, 2, 3]);
}
#[test]
fn dropping_a_guard_returns_its_slot() {
let pool = SlotPool::new(2);
let a = pool.try_acquire().unwrap();
let b = pool.try_acquire().unwrap();
assert!(pool.try_acquire().is_none());
assert!(!pool.all_free());
drop(a);
assert_eq!(pool.available(), 1);
assert!(pool.try_acquire().is_some());
drop(b);
assert!(pool.all_free());
}
}

View File

@@ -10,12 +10,58 @@ use dashmap::DashMap;
use once_cell::sync::Lazy;
use std::sync::Arc;
// Holds capture-specific resources instead of re-creating them *each frame*
pub struct CaptureResources {
/// How many frames may be in flight between the present hook and the encoder.
///
/// The path holds at most three at once — two queued for the encoder plus the
/// one it is working on — so four leaves a slot spare and the present hook
/// practically never finds the ring empty.
pub const CAPTURE_SLOTS: usize = 4;
/// One destination for the swapchain blit, with everything that belongs to it.
///
/// Every slot owns its image: the previous version rotated four command buffers
/// over a single shared destination, so a capture overwrote the frame the
/// encoder was still reading.
pub struct CaptureSlot {
pub image: vk::Image,
pub memory: vk::DeviceMemory,
/// Exported once at allocation and duplicated per frame. -1 if the export
/// failed, which sends that frame down the CPU readback path instead.
pub dmabuf_fd: std::os::raw::c_int,
pub stride: u32,
pub command_buffer: vk::CommandBuffer,
/// Signalled when this slot's blit has finished reading the swapchain and
/// writing the slot. The capture worker waits on it before handing the
/// DMA-BUF to the encoder, which reads it from a different VkDevice and so
/// cannot be synchronised with a semaphore.
pub fence: vk::Fence,
}
pub struct CaptureRing {
pub command_pool: vk::CommandPool,
pub command_buffers: [vk::CommandBuffer; 4],
pub fences: [vk::Fence; 4],
pub current: usize,
pub slots: Vec<CaptureSlot>,
pub size: (u32, u32, vk::Format),
/// Queue family the command pool was created for. Command buffers may only
/// be submitted to a queue of the family their pool belongs to, so a
/// present arriving on a different family rebuilds the ring rather than
/// submitting invalid work.
pub queue_family: u32,
/// Semaphores that were handed to a present which then failed.
///
/// Whether such a present waited on the semaphore is unknowable, so it can
/// neither be signalled again nor safely destroyed while it might still be
/// pending. They are set aside here and destroyed with the device. A
/// swapchain recreation retires at most one per image.
pub retired: Vec<vk::Semaphore>,
/// Signalled by the capture blit, waited on by the present that follows it.
///
/// Indexed by *swapchain image index*, not by ring slot. A binary semaphore
/// may not be re-signalled until its previous wait has completed, and the
/// only guarantee of that available here is the application's own acquire:
/// it cannot present image N again until it has reacquired it, and it
/// cannot reacquire it until the present that waited on this semaphore is
/// done.
pub present_wait: Vec<vk::Semaphore>,
}
// ── Per-pipeline records ──────────────────────────────────────────────────────
@@ -60,12 +106,11 @@ pub struct DeviceState {
pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>,
// Phase 4: final-frame capture (DMA-BUF exportable)
pub final_image: std::sync::Mutex<Option<vk::Image>>,
pub final_memory: std::sync::Mutex<Option<vk::DeviceMemory>>,
pub final_size: std::sync::Mutex<(u32, u32, vk::Format)>,
/// Row stride in bytes of `final_image`, queried once after allocation.
/// Zero until the first frame is captured.
pub final_stride: std::sync::atomic::AtomicU32,
pub capture_ring: std::sync::Mutex<Option<CaptureRing>>,
/// Which ring slots are free. Held separately from the ring itself so a
/// slot can be returned from the encoder thread without taking the lock
/// the present hook needs.
pub capture_slots: Arc<crate::slots::SlotPool>,
// Phase 4: swapchain tracking
pub swapchain: std::sync::Mutex<Option<vk::SwapchainKHR>>,
@@ -75,9 +120,13 @@ pub struct DeviceState {
/// Used to derive color space for the encoder (SDR vs HDR10 etc.).
pub swapchain_colorspace: std::sync::atomic::AtomicU32,
pub swapchain_extent: std::sync::Mutex<vk::Extent2D>,
/// Whether the swapchain was created with TRANSFER_SRC usage. When the
/// driver refuses it the layer falls back to the application's own usage
/// flags, and blitting from those images would be undefined — so capture
/// stays off for the life of that swapchain.
pub swapchain_transfer_src: std::sync::atomic::AtomicBool,
pub frame_counter: std::sync::atomic::AtomicU64,
pub largest_extent: std::sync::Mutex<vk::Extent2D>,
// Phase 3/4: per-frame HUD detection flags
pub hud_detected_frame: std::sync::atomic::AtomicBool,
@@ -88,32 +137,12 @@ pub struct DeviceState {
// Phase 7: encode + IPC pipeline (lazy-init on first frame)
pub encoder: std::sync::Mutex<Option<PipelineHandle>>,
// Re-usable capture resources (double-buffered)
pub capture_resources: std::sync::Mutex<Option<CaptureResources>>,
/// Dedicated queue for capture submissions (separate from game rendering).
pub capture_queue: std::sync::Mutex<vk::Queue>,
// Fake swapchain pool (headless — no real present)
pub fake_images: std::sync::Mutex<Vec<vk::Image>>,
pub fake_memories: std::sync::Mutex<Vec<vk::DeviceMemory>>,
pub fake_fds: std::sync::Mutex<Vec<std::os::raw::c_int>>,
pub fake_strides: std::sync::Mutex<Vec<u32>>,
pub fake_available: std::sync::Mutex<Vec<bool>>,
pub fake_image_count: std::sync::atomic::AtomicU32,
pub fake_swapchain: std::sync::Mutex<Option<vk::SwapchainKHR>>,
pub signal_queue: std::sync::Mutex<vk::Queue>,
pub next_acquire: std::sync::atomic::AtomicU32,
pub memory_properties: std::sync::Mutex<vk::PhysicalDeviceMemoryProperties>,
pub acquire_dummy_pool: std::sync::Mutex<vk::CommandPool>,
pub acquire_dummy_cb: std::sync::Mutex<vk::CommandBuffer>,
/// Cached DMA-BUF fd for final_memory (-1 = not cached).
/// Avoids a kernel ioctl per frame.
pub cached_dmabuf_fd: std::sync::atomic::AtomicI32,
// ── Frame-rate throttle ───────────────────────────────────────────
/// Target FPS for capture throttling (set from HUDLESS_FPS on pipeline init).
pub target_fps: std::sync::atomic::AtomicU32,
/// Timestamp of last captured frame (for rate limiting).
pub last_capture_time: std::sync::Mutex<Option<std::time::Instant>>,
/// Decides which presented frames are worth capturing. Consulted in the
/// present hook, before any GPU work is queued, so a dropped frame costs
/// nothing beyond the comparison.
pub frame_gate: std::sync::Mutex<crate::pacing::FrameGate>,
/// Channel for threaded capture worker (present → worker).
pub capture_tx: std::sync::Mutex<Option<std::sync::mpsc::Sender<crate::present::CaptureJob>>>,
}
@@ -151,5 +180,8 @@ pub static CB_STATE: Lazy<DashMap<u64, CbState>> = Lazy::new(DashMap::new);
/// VkQueue → device dispatch key
pub static QUEUE_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);
/// VkQueue → queue family index, recorded at vkGetDeviceQueue.
pub static QUEUE_TO_FAMILY: Lazy<DashMap<u64, u32>> = Lazy::new(DashMap::new);
/// VkCommandBuffer → device dispatch key
pub static CMD_BUF_TO_DEVICE_KEY: Lazy<DashMap<u64, usize>> = Lazy::new(DashMap::new);

View File

@@ -38,14 +38,28 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR(
let result = unsafe { create_fn(device, &modified_ci, p_allocator, p_swapchain) };
// If the driver rejects TRANSFER_SRC (e.g. composited window), try without.
let result = if result != vk::Result::SUCCESS {
unsafe { create_fn(device, ci, p_allocator, p_swapchain) }
} else {
let transfer_src = result == vk::Result::SUCCESS;
let result = if transfer_src {
result
} else {
unsafe { create_fn(device, ci, p_allocator, p_swapchain) }
};
if result != vk::Result::SUCCESS {
return result;
}
if !transfer_src {
log::warn!(
"swapchain refused TRANSFER_SRC — capture disabled for this swapchain. \
Blitting from images the driver did not grant transfer usage is undefined."
);
}
ds.swapchain_transfer_src
.store(transfer_src, Ordering::Relaxed);
// A fresh swapchain means fresh images behind the same indices. The
// per-image capture semaphores may still be pending on presents from the
// outgoing swapchain, so they are set aside rather than reused.
crate::capture::retire_all_present_semaphores(&ds);
*ds.swapchain.lock().unwrap() = Some(unsafe { *p_swapchain });
*ds.swapchain_format.lock().unwrap() = ci.image_format;