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>, - pub final_memory: std::sync::Mutex>, - 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>, + /// 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, // Phase 4: swapchain tracking pub swapchain: std::sync::Mutex>, @@ -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, + /// 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, // 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>, - // Re-usable capture resources (double-buffered) - pub capture_resources: std::sync::Mutex>, - /// Dedicated queue for capture submissions (separate from game rendering). - pub capture_queue: std::sync::Mutex, - // Fake swapchain pool (headless — no real present) - pub fake_images: std::sync::Mutex>, - pub fake_memories: std::sync::Mutex>, - pub fake_fds: std::sync::Mutex>, - pub fake_strides: std::sync::Mutex>, - pub fake_available: std::sync::Mutex>, - pub fake_image_count: std::sync::atomic::AtomicU32, - pub fake_swapchain: std::sync::Mutex>, - pub signal_queue: std::sync::Mutex, - pub next_acquire: std::sync::atomic::AtomicU32, - pub memory_properties: std::sync::Mutex, - pub acquire_dummy_pool: std::sync::Mutex, - pub acquire_dummy_cb: std::sync::Mutex, - /// 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>, + /// 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, /// Channel for threaded capture worker (present → worker). pub capture_tx: std::sync::Mutex>>, } @@ -151,5 +180,8 @@ pub static CB_STATE: Lazy> = Lazy::new(DashMap::new); /// VkQueue → device dispatch key pub static QUEUE_TO_DEVICE_KEY: Lazy> = Lazy::new(DashMap::new); +/// VkQueue → queue family index, recorded at vkGetDeviceQueue. +pub static QUEUE_TO_FAMILY: Lazy> = Lazy::new(DashMap::new); + /// VkCommandBuffer → device dispatch key pub static CMD_BUF_TO_DEVICE_KEY: Lazy> = Lazy::new(DashMap::new); diff --git a/apps/nescapture/src/swapchain.rs b/apps/nescapture/src/swapchain.rs index 270df5e6..25fb1d69 100644 --- a/apps/nescapture/src/swapchain.rs +++ b/apps/nescapture/src/swapchain.rs @@ -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;