From 15ad60bf49850cdd144de32a7c2bb65ea44bb0d4 Mon Sep 17 00:00:00 2001
From: Kristian Ollikainen <14197772+DatCaptainHorse@users.noreply.github.com>
Date: Tue, 15 Sep 2026 13:58:57 +0300
Subject: [PATCH] feat: nescapture pacing, improvements and deps update (#334)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Make nescapture better with proper queue family checks, FPS limiting,
semaphore usage and other.. also updated deps like pollster and
pixelforge.
Confidence Score: 5/5
The final review contains no accepted findings, so the PR appears safe
to merge.
Summary
- 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.
Reviews (1) · Last reviewed commit: ["feat: Update deps and remove
deprecated
..."](https://github.com/nestrilabs/nestri/commit/79e21f4aac6f5d15679072bf576ed8cb182805c3)
---------
Co-authored-by: DatCaptainHorse
Co-authored-by: Claude Opus 5
---
Cargo.lock | 11 +-
apps/nescapture/Cargo.toml | 7 +-
apps/nescapture/src/capture.rs | 469 ++++++++++++++++++++++---------
apps/nescapture/src/commands.rs | 12 -
apps/nescapture/src/device.rs | 154 +++-------
apps/nescapture/src/dispatch.rs | 14 +
apps/nescapture/src/encode.rs | 89 ++++--
apps/nescapture/src/lib.rs | 2 +
apps/nescapture/src/pacing.rs | 172 ++++++++++++
apps/nescapture/src/present.rs | 356 ++++++++++++++---------
apps/nescapture/src/slots.rs | 100 +++++++
apps/nescapture/src/state.rs | 104 ++++---
apps/nescapture/src/swapchain.rs | 20 +-
13 files changed, 1045 insertions(+), 465 deletions(-)
create mode 100644 apps/nescapture/src/pacing.rs
create mode 100644 apps/nescapture/src/slots.rs
diff --git a/Cargo.lock b/Cargo.lock
index e9207348..0ec1124a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -165,8 +165,8 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ash"
-version = "0.38.0+1.4.329"
-source = "git+https://github.com/ash-rs/ash?rev=55dd56906bbb5760e9e9e6c56f45be67f67e0649#55dd56906bbb5760e9e9e6c56f45be67f67e0649"
+version = "0.38.0+1.4.352"
+source = "git+https://github.com/ash-rs/ash?rev=f4c2ca3e4f6b998d5254ad101a32f024d87cdec2#f4c2ca3e4f6b998d5254ad101a32f024d87cdec2"
dependencies = [
"libloading",
]
@@ -3095,10 +3095,11 @@ dependencies = [
[[package]]
name = "pixelforge"
version = "0.9.1"
-source = "git+https://github.com/hgaiser/pixelforge.git?rev=2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379#2ee4d7ac6e470cc34270e48d6e9d6f3fc1d7c379"
+source = "git+https://github.com/hgaiser/pixelforge.git?rev=936d412e1a73917e0e108c4ab18bf5208b1681ac#936d412e1a73917e0e108c4ab18bf5208b1681ac"
dependencies = [
"ash",
"futures-channel",
+ "futures-core",
"thiserror 2.0.20",
"tracing",
]
@@ -3148,9 +3149,9 @@ dependencies = [
[[package]]
name = "pollster"
-version = "0.4.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
[[package]]
name = "polyval"
diff --git a/apps/nescapture/Cargo.toml b/apps/nescapture/Cargo.toml
index a3e447ac..a94986a4 100644
--- a/apps/nescapture/Cargo.toml
+++ b/apps/nescapture/Cargo.toml
@@ -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" }
-
diff --git a/apps/nescapture/src/capture.rs b/apps/nescapture/src/capture.rs
index 53a96d1b..b5c046cb 100644
--- a/apps/nescapture/src/capture.rs
+++ b/apps/nescapture/src/capture.rs
@@ -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,
+ 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 {
+ 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,
+) {
+ 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 = 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 {
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 {
- 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 {
+ 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(
diff --git a/apps/nescapture/src/commands.rs b/apps/nescapture/src/commands.rs
index 0f036ac4..7e3e7298 100644
--- a/apps/nescapture/src/commands.rs
+++ b/apps/nescapture/src/commands.rs
@@ -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
diff --git a/apps/nescapture/src/device.rs b/apps/nescapture/src/device.rs
index 9a1dcff2..0b553533 100644
--- a/apps/nescapture/src/device.rs
+++ b/apps/nescapture/src/device.rs
@@ -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 = 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 = 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);
}
}
diff --git a/apps/nescapture/src/dispatch.rs b/apps/nescapture/src/dispatch.rs
index b33af6b3..6c7b42ab 100644
--- a/apps/nescapture/src/dispatch.rs
+++ b/apps/nescapture/src/dispatch.rs
@@ -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,
+ pub destroy_semaphore: Option,
pub create_command_pool: PFN_vkCreateCommandPool,
pub destroy_command_pool: PFN_vkDestroyCommandPool,
pub reset_command_pool: PFN_vkResetCommandPool,
diff --git a/apps/nescapture/src/encode.rs b/apps/nescapture/src/encode.rs
index 103cb1a3..2212c7c6 100644
--- a/apps/nescapture/src/encode.rs
+++ b/apps/nescapture/src/encode.rs
@@ -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 {
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,
+}
+
+/// 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::(2);
- let (encoded_tx, encoded_rx) = mpsc::sync_channel::(2);
+ let (encoded_tx, encoded_rx) = mpsc::sync_channel::(2);
let (reconfig_tx, reconfig_rx) = mpsc::channel::();
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,
- encoded_tx: mpsc::SyncSender,
+ encoded_tx: mpsc::SyncSender,
shutdown: Arc,
) {
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 {
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,
+ /// Shared with the encoder thread, which honours it on the next frame.
+ idr_requested: Arc,
+ /// Zero point for wire timestamps.
+ epoch: Instant,
}
fn ipc_send_thread(
cfg: IpcConfig,
- encoded_rx: mpsc::Receiver,
+ encoded_rx: mpsc::Receiver,
shutdown: Arc,
) {
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,
diff --git a/apps/nescapture/src/lib.rs b/apps/nescapture/src/lib.rs
index 927bc732..057e4b09 100644
--- a/apps/nescapture/src/lib.rs
+++ b/apps/nescapture/src/lib.rs
@@ -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;
diff --git a/apps/nescapture/src/pacing.rs b/apps/nescapture/src/pacing.rs
new file mode 100644
index 00000000..12a87889
--- /dev/null
+++ b/apps/nescapture/src/pacing.rs
@@ -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,
+}
+
+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");
+ }
+}
diff --git a/apps/nescapture/src/present.rs b/apps/nescapture/src/present.rs
index c08b5bbf..ad32e2b3 100644
--- a/apps/nescapture/src/present.rs
+++ b/apps/nescapture/src/present.rs
@@ -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 {
+ 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 s.clone(),
None => {
@@ -118,97 +221,84 @@ pub fn start_capture_worker(ds_key: usize, capture_rx: mpsc::Receiver= 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 {
- 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,
- })
-}
diff --git a/apps/nescapture/src/slots.rs b/apps/nescapture/src/slots.rs
new file mode 100644
index 00000000..daae3a5c
--- /dev/null
+++ b/apps/nescapture/src/slots.rs
@@ -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>,
+ count: usize,
+}
+
+impl SlotPool {
+ pub fn new(count: usize) -> Arc {
+ 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) -> Option {
+ 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,
+ 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());
+ }
+}
diff --git a/apps/nescapture/src/state.rs b/apps/nescapture/src/state.rs
index 62e7b254..0732ac37 100644
--- a/apps/nescapture/src/state.rs
+++ b/apps/nescapture/src/state.rs
@@ -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,
+ 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,
+ /// 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,
}
// ── 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