mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-08 00:17:06 +02:00
wip(nescapture): per-window capture
This commit is contained in:
@@ -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 ────────────────────────────
|
||||
|
||||
@@ -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<u8> {
|
||||
/// `STREAM_ENCODER_HELLO` for one source: its id and `NESTRI_LAUNCH_ID`.
|
||||
fn encoder_hello(source: u32) -> Vec<u8> {
|
||||
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<crate::state::Window>,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub vk_format: u32,
|
||||
@@ -925,6 +927,9 @@ pub struct PipelineConfig {
|
||||
pub codec_request: Option<String>,
|
||||
pub ipc_path: std::path::PathBuf,
|
||||
pub physical_device: Option<ash::vk::PhysicalDevice>,
|
||||
/// 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<Self> {
|
||||
@@ -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]
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<crate::state::Window>,
|
||||
queue: vk::Queue,
|
||||
pi: &vk::PresentInfoKHR,
|
||||
) -> Option<Submission> {
|
||||
@@ -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<crate::state::Window>, 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<FrameSource> {
|
||||
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!(
|
||||
|
||||
@@ -208,6 +208,51 @@ pub struct DeviceState {
|
||||
pub hudless_memory: std::sync::Mutex<Option<vk::DeviceMemory>>,
|
||||
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<crate::pacing::FramePacer>,
|
||||
/// 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<Option<std::time::Instant>>,
|
||||
|
||||
/// 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<u32, Arc<Window>>,
|
||||
/// 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<Option<Arc<Window>>>,
|
||||
}
|
||||
|
||||
/// 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<DeviceState>,
|
||||
/// 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<Option<CaptureRing>>,
|
||||
/// 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<Option<PipelineHandle>>,
|
||||
|
||||
// ── 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<crate::pacing::FrameGate>,
|
||||
/// 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<crate::pacing::FramePacer>,
|
||||
/// 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<Option<std::time::Instant>>,
|
||||
|
||||
/// 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<DeviceState>, 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<DashMap<u64, u32>> = 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> {
|
||||
|
||||
@@ -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<F>(instance: vk::Instance, name: &CStr) -> Option<F> {
|
||||
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::<vk::PFN_vkCreateXcbSurfaceKHR>(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::<vk::PFN_vkCreateXlibSurfaceKHR>(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::<vk::PFN_vkDestroySurfaceKHR>(instance, c"vkDestroySurfaceKHR") }
|
||||
{
|
||||
unsafe { destroy(instance, surface, p_allocator) };
|
||||
}
|
||||
}
|
||||
@@ -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<std::sync::Arc<crate::state::Window>> {
|
||||
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<std::sync::Arc<crate::state::DeviceS
|
||||
}
|
||||
|
||||
fn record_acquire(ds: &crate::state::DeviceState, waited: std::time::Duration) {
|
||||
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.timing.acquire.record(waited);
|
||||
|
||||
Reference in New Issue
Block a user