From 10bb1405d69738fc2f46e322d1e1bbdfd90b75a5 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Sun, 25 Oct 2026 13:59:47 +0300 Subject: [PATCH] wip(nescapture): per-window capture --- apps/nescapture/src/device.rs | 56 +++------------- apps/nescapture/src/encode.rs | 36 +++++----- apps/nescapture/src/lib.rs | 5 ++ apps/nescapture/src/present.rs | 77 ++++++++++++++------- apps/nescapture/src/state.rs | 112 +++++++++++++++++++++++++------ apps/nescapture/src/surface.rs | 81 ++++++++++++++++++++++ apps/nescapture/src/swapchain.rs | 95 +++++++++++++++++++++----- 7 files changed, 335 insertions(+), 127 deletions(-) create mode 100644 apps/nescapture/src/surface.rs diff --git a/apps/nescapture/src/device.rs b/apps/nescapture/src/device.rs index e11321b9..8c0ac122 100644 --- a/apps/nescapture/src/device.rs +++ b/apps/nescapture/src/device.rs @@ -249,31 +249,17 @@ 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)), - 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), - ring_generation: std::sync::atomic::AtomicU64::new(0), hud_detected_frame: std::sync::atomic::AtomicBool::new(false), pending_capture_frame: std::sync::atomic::AtomicBool::new(false), capture_injected_frame: std::sync::atomic::AtomicBool::new(false), skipped_draws_frame: std::sync::atomic::AtomicU32::new(0), - encoder: std::sync::Mutex::new(None), - - frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()), frame_pacer: std::sync::Mutex::new(crate::pacing::FramePacer::from_env()), last_present_return: std::sync::Mutex::new(None), - encoder_starting: std::sync::atomic::AtomicBool::new(false), + windows: DashMap::new(), + newest_window: std::sync::Mutex::new(None), }); DEVICE_STATE.insert(key, dev_state); @@ -299,38 +285,12 @@ pub unsafe extern "system" fn vkDestroyDevice( ds.pipeline_registry.len(), ); - // ── 1. Shut down the encode pipeline ────────────────────────────────── - // - // Waited for, not just signalled. On a shared device the encoder, its - // converter and their images are objects on this very device, and - // destroying the device under them is a use-after-free. The receive - // timeout bounds how long the thread takes to notice. - let handle = ds.encoder.lock().ok().and_then(|mut g| g.take()); - if let Some(handle) = handle { - if handle.finish(std::time::Duration::from_secs(2)) { - log::info!("encoder pipeline stopped"); - } else { - log::error!("encoder thread did not stop in time; destroying the device anyway"); - } - } - - // ── 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 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() - ); - } - unsafe { crate::capture::destroy_capture_ring(&ds, ring) }; - log::debug!("capture ring destroyed"); - } + // ── 1–2. Every window: its encoder, then its capture ring ───────────── + *ds.newest_window.lock().unwrap() = None; + let windows: Vec<_> = ds.windows.iter().map(|e| e.value().clone()).collect(); + ds.windows.clear(); + for window in windows { + crate::swapchain::retire_window(&window); } // ── 3. Free hudless_image / hudless_memory ──────────────────────────── diff --git a/apps/nescapture/src/encode.rs b/apps/nescapture/src/encode.rs index 332abd9c..53655b84 100644 --- a/apps/nescapture/src/encode.rs +++ b/apps/nescapture/src/encode.rs @@ -185,11 +185,11 @@ fn relay_legacy_commands(own: std::path::PathBuf) { } } -/// `STREAM_ENCODER_HELLO` for this process: its pid and `NESTRI_LAUNCH_ID`. -fn encoder_hello() -> Vec { +/// `STREAM_ENCODER_HELLO` for one source: its id and `NESTRI_LAUNCH_ID`. +fn encoder_hello(source: u32) -> Vec { let launch = std::env::var("NESTRI_LAUNCH_ID").unwrap_or_default(); encode_ipc_frame_from( - std::process::id(), + source, nesprotocol::STREAM_ENCODER_HELLO, 0, 0, @@ -324,6 +324,8 @@ pub struct CapturedFrame { /// now does the waiting that a separate capture thread used to do, and that /// work is per-device. pub ds_key: usize, + /// The window this frame was presented to: its ring holds the slot. + pub window: std::sync::Arc, pub width: u32, pub height: u32, pub vk_format: u32, @@ -925,6 +927,9 @@ pub struct PipelineConfig { pub codec_request: Option, pub ipc_path: std::path::PathBuf, pub physical_device: Option, + /// What frames and commands are tagged with: this process, or one of its + /// windows. See `Window::source`. + pub source: u32, } impl PipelineConfig { pub fn from_env(width: u32, height: u32) -> Option { @@ -961,6 +966,7 @@ impl PipelineConfig { pixel_format, codec_request: std::env::var("NESCAPTURE_CODEC").ok(), ipc_path, + source: std::process::id(), physical_device: None, }) } @@ -1204,6 +1210,7 @@ impl PipelineHandle { // is, so the listener answers a capability message without needing one. let listener_host_caps = device_caps; let listener_forced = config.codec_request.clone(); + let cmd_source = config.source; thread::Builder::new() .name("nescapture-idr".into()) .spawn(move || { @@ -1211,9 +1218,10 @@ impl PipelineHandle { // in one box, one shared path meant the second encoder took it // from the first, and every keyframe request and bitrate change // after that reached only the newer game. - let cmd_path = std::path::PathBuf::from(nesprotocol::capture_cmd_socket( - std::process::id(), - )); + // And one per window, so a pause or keyframe request names a + // window rather than everything its process draws. + let cmd_path = + std::path::PathBuf::from(nesprotocol::capture_cmd_socket(cmd_source)); let _ = std::fs::remove_file(&cmd_path); let sock = match std::os::unix::net::UnixDatagram::bind(&cmd_path) { Ok(s) => { @@ -1232,7 +1240,9 @@ impl PipelineHandle { // The shared path too, relayed onto this one, so a hub or a // compositor that predates per-process sockets still reaches the // newest encoder exactly as it did. - relay_legacy_commands(cmd_path.clone()); + if cmd_source == std::process::id() { + relay_legacy_commands(cmd_path.clone()); + } let mut buf = [0u8; 128]; loop { match sock.recv(&mut buf) { @@ -1777,13 +1787,7 @@ fn encoder_thread( // its own between the present hook and here; it is cheaper on this one, // because the blit it waits for was submitted a frame earlier and has // already completed, and every frame saves a channel and a wakeup. - let Some(ds) = crate::state::DEVICE_STATE - .get(&raw.ds_key) - .map(|s| s.clone()) - else { - log::error!("encoder: device state gone"); - break; - }; + let ds = raw.window.clone(); let Some(mut source) = crate::present::resolve_source(&ds, &raw) else { continue; }; @@ -3087,7 +3091,7 @@ fn ipc_send_thread( // Tagged with this process, so the hub can tell which // window the frame is, ref(d-0085). let ipc_frame = encode_ipc_frame_from( - std::process::id(), + cfg.source, STREAM_VIDEO, protocol_codec, flags, @@ -3101,7 +3105,7 @@ fn ipc_send_thread( // process's environment, and a keyframe is where a late // hub starts anyway. let messages = if pkt.is_key_frame { - vec![encoder_hello(), ipc_frame] + vec![encoder_hello(cfg.source), ipc_frame] } else { vec![ipc_frame] }; diff --git a/apps/nescapture/src/lib.rs b/apps/nescapture/src/lib.rs index 9cd337e8..871d675a 100644 --- a/apps/nescapture/src/lib.rs +++ b/apps/nescapture/src/lib.rs @@ -39,6 +39,7 @@ mod shader; mod shared; mod slots; mod state; +mod surface; mod swapchain; mod timing; @@ -56,6 +57,7 @@ use framebuffer::{ use instance::{vkCreateInstance, vkDestroyInstance}; use pipeline::{vkCreateGraphicsPipelines, vkDestroyPipeline}; use present::vkQueuePresentKHR; +use surface::{vkCreateXcbSurfaceKHR, vkCreateXlibSurfaceKHR, vkDestroySurfaceKHR}; use shader::{vkCreateShaderModule, vkDestroyShaderModule}; use swapchain::{ vkAcquireNextImage2KHR, vkAcquireNextImageKHR, vkCreateSwapchainKHR, vkDestroySwapchainKHR, @@ -220,6 +222,9 @@ pub unsafe extern "system" fn vkGetInstanceProcAddr( b"vkCreateInstance" => layer_fn!(vkCreateInstance), b"vkDestroyInstance" => layer_fn!(vkDestroyInstance), b"vkCreateDevice" => layer_fn!(vkCreateDevice), + b"vkCreateXcbSurfaceKHR" => layer_fn!(vkCreateXcbSurfaceKHR), + b"vkCreateXlibSurfaceKHR" => layer_fn!(vkCreateXlibSurfaceKHR), + b"vkDestroySurfaceKHR" => layer_fn!(vkDestroySurfaceKHR), _ => {} } diff --git a/apps/nescapture/src/present.rs b/apps/nescapture/src/present.rs index 34b0ba5e..b17dbcc6 100644 --- a/apps/nescapture/src/present.rs +++ b/apps/nescapture/src/present.rs @@ -32,6 +32,12 @@ pub unsafe extern "system" fn vkQueuePresentKHR( // it is the game's own frame time with this layer's cost excluded — the // three spans then partition the wall clock between presents exactly. let entered = std::time::Instant::now(); + let pi = unsafe { &*p_present_info }; + // The window this present draws, when it is one: its own encoder, ring and + // gate. A present naming several swapchains is passed through untouched. + let window = (pi.swapchain_count == 1 && !pi.p_swapchains.is_null()) + .then(|| crate::swapchain::window_of(&ds, unsafe { *pi.p_swapchains })) + .flatten(); note_present(&ds, PresentStep::InHook); ds.frame_counter.fetch_add(1, Ordering::Relaxed); @@ -40,7 +46,8 @@ pub unsafe extern "system" fn vkQueuePresentKHR( ds.capture_injected_frame.store(false, Ordering::Relaxed); ds.skipped_draws_frame.store(0, Ordering::Relaxed); - if let Ok(enc) = ds.encoder.lock() + if let Some(window) = &window + && let Ok(enc) = window.encoder.lock() && let Some(ref h) = *enc { h.present_attempts.fetch_add(1, Ordering::Relaxed); @@ -51,18 +58,15 @@ pub unsafe extern "system" fn vkQueuePresentKHR( } } - let pi = unsafe { &*p_present_info }; - // 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(); - let submission = if single_swapchain { - unsafe { try_capture(&ds, queue, pi) } - } else { - None + let submission = match &window { + Some(window) if single_swapchain => unsafe { try_capture(window, queue, pi) }, + _ => None, }; let down_us = std::cell::Cell::new(std::time::Duration::ZERO); @@ -80,7 +84,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR( let Some(submission) = submission else { let r = call_down(p_present_info); - finish(&ds, entered, down_us.get()); + finish(&ds, window.as_deref(), entered, down_us.get()); return r; }; @@ -105,15 +109,17 @@ pub unsafe extern "system" fn vkQueuePresentKHR( // Only hand the frame on once the present has been accepted. A failed // present drops the job, which returns the slot. + // A submission exists only for a window. + let window = window.expect("a capture without a window"); if matches!(result, vk::Result::SUCCESS | vk::Result::SUBOPTIMAL_KHR) { - queue_for_encode(&ds, submission); + queue_for_encode(&window, 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); + capture::retire_present_semaphore(&window, image_index); } - finish(&ds, entered, down_us.get()); + finish(&ds, Some(&window), entered, down_us.get()); result } @@ -122,7 +128,12 @@ pub unsafe extern "system" fn vkQueuePresentKHR( /// `layer` is everything in this hook that is not the down-call, both sides of /// it added together, so `gap + layer + down` accounts for the wall clock /// between one present and the next with nothing unattributed. -fn finish(ds: &crate::state::DeviceState, entered: std::time::Instant, down: std::time::Duration) { +fn finish( + ds: &crate::state::DeviceState, + window: Option<&crate::state::Window>, + entered: std::time::Instant, + down: std::time::Duration, +) { // Everything this hook cost, before any deliberate waiting. let worked = std::time::Instant::now(); @@ -148,7 +159,8 @@ fn finish(ds: &crate::state::DeviceState, entered: std::time::Instant, down: std // and the overshoot lands nowhere else — the present-return is stamped // after it, so `gap` cannot show it either. let held = now.saturating_duration_since(worked); - if let Ok(enc) = ds.encoder.lock() + if let Some(window) = window + && let Ok(enc) = window.encoder.lock() && let Some(ref h) = *enc { // `layer` is this layer's cost, so the hold comes out of it: it is @@ -171,8 +183,14 @@ fn finish(ds: &crate::state::DeviceState, entered: std::time::Instant, down: std /// Tell the stall watchdog where the game's present thread is. A no-op until /// the pipeline exists. +/// +/// The device's newest window stands for the device: the watchdog asks where +/// the present thread is, which is one answer however many windows it draws. pub fn note_present(ds: &crate::state::DeviceState, step: PresentStep) { - if let Ok(enc) = ds.encoder.lock() + let Some(window) = ds.newest_window.lock().ok().and_then(|w| w.clone()) else { + return; + }; + if let Ok(enc) = window.encoder.lock() && let Some(ref h) = *enc { h.note_present(step); @@ -191,7 +209,7 @@ struct Submission { } unsafe fn try_capture( - ds: &crate::state::DeviceState, + ds: &std::sync::Arc, queue: vk::Queue, pi: &vk::PresentInfoKHR, ) -> Option { @@ -240,7 +258,7 @@ unsafe fn try_capture( // Before the blit, not after it. A frame captured with nowhere to send it // costs a slot, a copy and an export for nothing. let ds_key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) }; - if !encoder_ready(ds, ds_key, sc_ext.width, sc_ext.height) { + if !encoder_ready(ds, sc_ext.width, sc_ext.height) { return None; } @@ -283,7 +301,7 @@ unsafe fn try_capture( }) } -fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) { +fn queue_for_encode(ds: &std::sync::Arc, submission: Submission) { let ds_key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) }; let Ok(enc) = ds.encoder.lock() else { @@ -297,6 +315,7 @@ fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) { // does, which is the backpressure the ring was always providing. handle.push_frame(CapturedFrame { ds_key, + window: ds.clone(), width: submission.width, height: submission.height, vk_format: submission.sc_fmt.as_raw() as u32, @@ -315,7 +334,10 @@ fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) { /// and doing it after a blit would waste one. It runs once, on a thread of its /// own, and frames presented in the meantime are skipped before any GPU work is /// queued for them. -fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, height: u32) -> bool { +fn encoder_ready(window: &crate::state::Window, width: u32, height: u32) -> bool { + let ds = window; + let ds_key = unsafe { crate::dispatch_key(ds.raw.as_raw() as *const c_void) }; + let native = window.native; if let Ok(enc) = ds.encoder.lock() { if enc.is_some() { return true; @@ -327,13 +349,17 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig std::thread::Builder::new() .name("nescapture-encoder-init".into()) .spawn(move || { - let Some(ds) = DEVICE_STATE.get(&ds_key).map(|s| s.clone()) else { + let Some(ds) = DEVICE_STATE + .get(&ds_key) + .and_then(|d| d.windows.get(&native).map(|w| w.clone())) + else { return; }; - let Some(cfg) = PipelineConfig::from_env(width, height) else { + let Some(mut cfg) = PipelineConfig::from_env(width, height) else { log::error!("no encode pipeline configuration; capture disabled"); return; }; + cfg.source = ds.source; // On the game's own device where it was created for that, and // on a device of the encoder's own where it was not, or where // adopting it fails. @@ -359,7 +385,7 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig let on_shared = shared.is_some(); match PipelineHandle::new(cfg, shared) { Ok(h) => { - h.set_stall_probe(Box::new(move || describe_gpu(ds_key))); + h.set_stall_probe(Box::new(move || describe_gpu(ds_key, native))); // Before the handle is published: the first capture after // it builds the ring, and must build it for this device. ds.shared_active.store(on_shared, Ordering::Release); @@ -391,7 +417,7 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig /// submitted a whole frame earlier and has long since completed — and takes a /// channel and a wakeup out of every frame's path. pub fn resolve_source( - ds: &crate::state::DeviceState, + ds: &crate::state::Window, frame: &CapturedFrame, ) -> Option { let slot_index = frame.slot.as_ref()?.index(); @@ -440,8 +466,11 @@ pub fn resolve_source( /// it, and whatever the game is waiting for is not GPU work on that queue. /// /// Never blocks. It runs exactly when something may be holding a lock forever. -fn describe_gpu(ds_key: usize) -> String { - let Some(ds) = DEVICE_STATE.get(&ds_key).map(|s| s.clone()) else { +fn describe_gpu(ds_key: usize, native: u32) -> String { + let Some(ds) = DEVICE_STATE + .get(&ds_key) + .and_then(|d| d.windows.get(&native).map(|w| w.clone())) + else { return "device gone".into(); }; let slots = format!( diff --git a/apps/nescapture/src/state.rs b/apps/nescapture/src/state.rs index b720cdab..d7e6db34 100644 --- a/apps/nescapture/src/state.rs +++ b/apps/nescapture/src/state.rs @@ -208,6 +208,51 @@ pub struct DeviceState { pub hudless_memory: std::sync::Mutex>, pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>, + pub frame_counter: std::sync::atomic::AtomicU64, + + // Phase 3/4: per-frame HUD detection flags + pub hud_detected_frame: std::sync::atomic::AtomicBool, + pub pending_capture_frame: std::sync::atomic::AtomicBool, + pub capture_injected_frame: std::sync::atomic::AtomicBool, + pub skipped_draws_frame: std::sync::atomic::AtomicU32, + + + // ── Frame-rate throttle ─────────────────────────────────────────── + /// Holds the game to the target rate. The gate decides what is captured; + /// this decides how fast the game is allowed to produce frames, which no + /// compositor can do once a player turns V-Sync off. + pub frame_pacer: std::sync::Mutex, + /// When the previous `vkQueuePresentKHR` returned to the game. + /// + /// The base for the `gap` span: time from here to the next present's + /// arrival is the game's own, with nothing this layer does inside it. + pub last_present_return: std::sync::Mutex>, + + /// One per native window: each window this device draws is captured and + /// encoded on its own, so a dialog never takes over the main window's + /// stream. Keyed by [`Window::native`]; a swapchain recreated for the same + /// window keeps its entry, and with it the stream. Every non-X11 surface + /// shares key 0, which is how a single window was always handled. + pub windows: DashMap>, + /// The most recently created swapchain's window. What frame-wide work with + /// no swapchain in hand (HUD-less copies, acquire timing) reads. + pub newest_window: std::sync::Mutex>>, +} + +/// Everything that belongs to one presented window rather than to the device. +/// +/// Dereferences to its device, so code that captures a window reads device +/// state (`fp`, `raw`, `shared`) through it unchanged and its own state +/// directly. +pub struct Window { + pub device: Arc, + /// The native window this swapchain presents to: an X11 window id, or 0 + /// when the surface was not an X11 one. + pub native: u32, + /// The id frames and commands are tagged with: the native window marked by + /// [`WINDOW_SOURCE_BIT`], or this process's id when there is no native + /// window, which is how a lone fullscreen game has always been named. + pub source: u32, // Phase 4: final-frame capture pub capture_ring: std::sync::Mutex>, /// Which ring slots are free. Held separately from the ring itself so a @@ -229,35 +274,14 @@ pub struct DeviceState { /// stays off for the life of that swapchain. pub swapchain_transfer_src: std::sync::atomic::AtomicBool, - pub frame_counter: std::sync::atomic::AtomicU64, - /// Rings built for this device so far. Names the current one; see + /// Rings built for this window so far. Names the current one; see /// [`CaptureRing::generation`]. pub ring_generation: std::sync::atomic::AtomicU64, - - // Phase 3/4: per-frame HUD detection flags - pub hud_detected_frame: std::sync::atomic::AtomicBool, - pub pending_capture_frame: std::sync::atomic::AtomicBool, - pub capture_injected_frame: std::sync::atomic::AtomicBool, - pub skipped_draws_frame: std::sync::atomic::AtomicU32, - - // Phase 7: encode + IPC pipeline (lazy-init on first frame) pub encoder: std::sync::Mutex>, - - // ── Frame-rate throttle ─────────────────────────────────────────── /// 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, - /// Holds the game to the target rate. The gate decides what is captured; - /// this decides how fast the game is allowed to produce frames, which no - /// compositor can do once a player turns V-Sync off. - pub frame_pacer: std::sync::Mutex, - /// When the previous `vkQueuePresentKHR` returned to the game. - /// - /// The base for the `gap` span: time from here to the next present's - /// arrival is the game's own, with nothing this layer does inside it. - pub last_present_return: std::sync::Mutex>, - /// Whether a thread has already been started to build the encode pipeline. /// /// The build is slow and happens once; without this latch every present @@ -265,6 +289,50 @@ pub struct DeviceState { pub encoder_starting: std::sync::atomic::AtomicBool, } +/// Marks a capture source as a window rather than a process. Process ids stay +/// far below it, so the two never collide. +pub const WINDOW_SOURCE_BIT: u32 = 0x8000_0000; + +impl Window { + pub fn new(device: Arc, native: u32, swapchain: vk::SwapchainKHR) -> Self { + Self { + device, + native, + source: if native == 0 { + std::process::id() + } else { + WINDOW_SOURCE_BIT | native + }, + capture_ring: std::sync::Mutex::new(None), + capture_slots: crate::slots::SlotPool::new(CAPTURE_SLOTS), + swapchain: std::sync::Mutex::new(Some(swapchain)), + swapchain_images: std::sync::Mutex::new(Vec::new()), + swapchain_format: std::sync::Mutex::new(vk::Format::UNDEFINED), + swapchain_colorspace: std::sync::atomic::AtomicU32::new(0), + swapchain_extent: std::sync::Mutex::new(vk::Extent2D { + width: 0, + height: 0, + }), + swapchain_transfer_src: std::sync::atomic::AtomicBool::new(false), + ring_generation: std::sync::atomic::AtomicU64::new(0), + encoder: std::sync::Mutex::new(None), + frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()), + encoder_starting: std::sync::atomic::AtomicBool::new(false), + } + } +} + +impl std::ops::Deref for Window { + type Target = DeviceState; + fn deref(&self) -> &DeviceState { + &self.device + } +} + +/// `VkSurfaceKHR` → the X11 window it was created for. Surfaces belong to the +/// instance, swapchains to a device, so this is process-wide. +pub static SURFACE_WINDOW: Lazy> = Lazy::new(DashMap::new); + impl DeviceState { /// The shared device, when the encoder is actually running on it. pub fn shared_encoder(&self) -> Option<&crate::shared::SharedDevice> { diff --git a/apps/nescapture/src/surface.rs b/apps/nescapture/src/surface.rs new file mode 100644 index 00000000..fdd49aee --- /dev/null +++ b/apps/nescapture/src/surface.rs @@ -0,0 +1,81 @@ +//! Which native window each surface draws to. +//! +//! A layer sees swapchains, and a swapchain names only a `VkSurfaceKHR`. The +//! compositor names windows. Recording the X11 window behind each surface is +//! what lets a capture say which window its frames belong to, so a process +//! drawing several windows -- a browser, a launcher with dialogs -- streams each +//! one on its own instead of all of them through one picture. +//! +//! Wayland surfaces are not recorded: a `wl_surface` is a client-side pointer +//! with no id the compositor would recognise. They keep the process as their +//! name, as every surface did before. + +use crate::dispatch_key; +use crate::state::{INSTANCE_STATE, SURFACE_WINDOW}; +use ash::vk::{self, Handle}; +use std::ffi::CStr; +use std::os::raw::c_void; + +/// The next layer's entry point for `name` on `instance`. +unsafe fn next(instance: vk::Instance, name: &CStr) -> Option { + let key = unsafe { dispatch_key(instance.as_raw() as *const c_void) }; + let istate = INSTANCE_STATE.get(&key)?; + let f = unsafe { (istate.get_instance_proc_addr)(instance, name.as_ptr()) }?; + Some(unsafe { std::mem::transmute_copy::<_, F>(&f) }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn vkCreateXcbSurfaceKHR( + instance: vk::Instance, + p_create_info: *const vk::XcbSurfaceCreateInfoKHR<'_>, + p_allocator: *const vk::AllocationCallbacks<'_>, + p_surface: *mut vk::SurfaceKHR, +) -> vk::Result { + let Some(create) = + (unsafe { next::(instance, c"vkCreateXcbSurfaceKHR") }) + else { + return vk::Result::ERROR_EXTENSION_NOT_PRESENT; + }; + let result = unsafe { create(instance, p_create_info, p_allocator, p_surface) }; + if result == vk::Result::SUCCESS { + let window = unsafe { (*p_create_info).window }; + SURFACE_WINDOW.insert(unsafe { (*p_surface).as_raw() }, window); + } + result +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn vkCreateXlibSurfaceKHR( + instance: vk::Instance, + p_create_info: *const vk::XlibSurfaceCreateInfoKHR<'_>, + p_allocator: *const vk::AllocationCallbacks<'_>, + p_surface: *mut vk::SurfaceKHR, +) -> vk::Result { + let Some(create) = + (unsafe { next::(instance, c"vkCreateXlibSurfaceKHR") }) + else { + return vk::Result::ERROR_EXTENSION_NOT_PRESENT; + }; + let result = unsafe { create(instance, p_create_info, p_allocator, p_surface) }; + if result == vk::Result::SUCCESS { + // An X11 window id is 29 bits wide whatever `Window`'s C type is. + let window = unsafe { (*p_create_info).window } as u32; + SURFACE_WINDOW.insert(unsafe { (*p_surface).as_raw() }, window); + } + result +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn vkDestroySurfaceKHR( + instance: vk::Instance, + surface: vk::SurfaceKHR, + p_allocator: *const vk::AllocationCallbacks<'_>, +) { + // Before the call down: a handle can be reused the moment it is freed. + SURFACE_WINDOW.remove(&surface.as_raw()); + if let Some(destroy) = + unsafe { next::(instance, c"vkDestroySurfaceKHR") } + { + unsafe { destroy(instance, surface, p_allocator) }; + } +} diff --git a/apps/nescapture/src/swapchain.rs b/apps/nescapture/src/swapchain.rs index 42986a52..01a76f22 100644 --- a/apps/nescapture/src/swapchain.rs +++ b/apps/nescapture/src/swapchain.rs @@ -53,13 +53,30 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR( Blitting from images the driver did not grant transfer usage is undefined." ); } - ds.swapchain_transfer_src + // One window per native window: a swapchain recreated for the same X11 + // window keeps the window it had, and with it the stream. + let swapchain = unsafe { *p_swapchain }; + let native = crate::state::SURFACE_WINDOW + .get(&ci.surface.as_raw()) + .map(|w| *w) + .unwrap_or(0); + let window = match ds.windows.get(&native).map(|w| w.clone()) { + Some(window) => window, + None => { + let window = + std::sync::Arc::new(crate::state::Window::new(ds.clone(), native, swapchain)); + ds.windows.insert(native, window.clone()); + window + } + }; + window + .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); + crate::capture::retire_all_present_semaphores(&window); // Those fresh images also make every recorded blit invalid: a recording // names its source image by handle, and the handles behind these indices @@ -67,11 +84,11 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR( // `capture_present_frame` catches a resize on its own, but a swapchain // recreated at the same size — which is the common case, on a format or // present-mode change — looks identical to it. - crate::capture::invalidate_recorded_blits(&ds); + crate::capture::invalidate_recorded_blits(&window); - *ds.swapchain.lock().unwrap() = Some(unsafe { *p_swapchain }); - *ds.swapchain_format.lock().unwrap() = ci.image_format; - *ds.swapchain_extent.lock().unwrap() = ci.image_extent; + *window.swapchain.lock().unwrap() = Some(swapchain); + *window.swapchain_format.lock().unwrap() = ci.image_format; + *window.swapchain_extent.lock().unwrap() = ci.image_extent; // The colour space the layer below us was asked for, which is the one the // game asked for in every configuration we ship. // @@ -97,8 +114,10 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR( // HDR for wrong HDR. Anyone re-enabling it has to give this process a // channel to the compositor first, since the true colour space exists only // there. - ds.swapchain_colorspace + window + .swapchain_colorspace .store(ci.image_color_space.as_raw() as u32, Ordering::Relaxed); + *ds.newest_window.lock().unwrap() = Some(window.clone()); // `info`, not `debug`, and the present mode is why. It decides whether the // compositor paces this game at all: a FIFO swapchain waits on @@ -112,8 +131,9 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR( // and VKD3D choose the Vulkan present mode themselves, and what they pick // from a given setting is theirs to decide. log::info!( - "swapchain created — format={:?} colorspace={:?} extent={}x{} present_mode={:?} \ - min_images={}", + "swapchain created — window={:#x} format={:?} colorspace={:?} extent={}x{} \ + present_mode={:?} min_images={}", + native, ci.image_format, ci.image_color_space, ci.image_extent.width, @@ -145,18 +165,54 @@ pub unsafe extern "system" fn vkDestroySwapchainKHR( // created and its images fetched, and clearing then would leave the // live swapchain with no images: every present after would skip // capture, silently, until the next recreation. - let mut current = ds.swapchain.lock().unwrap(); - if is_current(*current, swapchain) { - *current = None; - *ds.swapchain_images.lock().unwrap() = Vec::new(); + if let Some(window) = window_of(&ds, swapchain) { + *window.swapchain.lock().unwrap() = None; + *window.swapchain_images.lock().unwrap() = Vec::new(); } - drop(current); if let Some(destroy_fn) = ds.fp.destroy_swapchain_khr { unsafe { destroy_fn(device, swapchain, p_allocator) }; } } } +/// The window whose current swapchain is `swapchain`, if any. +pub fn window_of( + ds: &crate::state::DeviceState, + swapchain: vk::SwapchainKHR, +) -> Option> { + ds.windows + .iter() + .find(|w| is_current(*w.swapchain.lock().unwrap(), swapchain)) + .map(|w| w.value().clone()) +} + +/// Stop a window's encoder and free its capture ring. +/// +/// The encoder is waited for, not just signalled: on a shared device the +/// encoder, its converter and their images are objects on the device, and +/// destroying the device under them is a use-after-free. Its receive timeout +/// bounds how long the thread takes to notice. +pub fn retire_window(window: &crate::state::Window) { + let handle = window.encoder.lock().ok().and_then(|mut g| g.take()); + if let Some(handle) = handle { + if handle.finish(std::time::Duration::from_secs(2)) { + log::info!("encoder pipeline stopped (window {:#x})", window.native); + } else { + log::error!("encoder thread did not stop in time; destroying the device anyway"); + } + } + let ring = window.capture_ring.lock().unwrap().take(); + if let Some(ring) = ring { + if !window.capture_slots.all_free() { + log::warn!( + "window destroyed with {} capture slot(s) still in flight", + crate::state::CAPTURE_SLOTS - window.capture_slots.available() + ); + } + unsafe { crate::capture::destroy_capture_ring(window, ring) }; + } +} + #[unsafe(no_mangle)] pub unsafe extern "system" fn vkGetSwapchainImagesKHR( device: vk::Device, @@ -188,10 +244,12 @@ pub unsafe extern "system" fn vkGetSwapchainImagesKHR( } // Images of a retired swapchain are not the ones capture reads. - if !p_swapchain_images.is_null() && is_current(*ds.swapchain.lock().unwrap(), swapchain) { + if !p_swapchain_images.is_null() + && let Some(window) = window_of(&ds, swapchain) + { let count = unsafe { *p_swapchain_image_count as usize }; let images = unsafe { std::slice::from_raw_parts(p_swapchain_images, count) }; - *ds.swapchain_images.lock().unwrap() = images.to_vec(); + *window.swapchain_images.lock().unwrap() = images.to_vec(); log::debug!("swapchain images — {} images", count); } @@ -225,7 +283,10 @@ fn device_for(device: vk::Device) -> Option