feat: nescapture capture improvements and drive mounts (#337)

Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Ollikainen
2026-09-18 22:58:22 +03:00
committed by GitHub
parent ebc0242b49
commit 6811c93d51
32 changed files with 3677 additions and 691 deletions

View File

@@ -109,6 +109,11 @@ impl CompositorHandler for NescopeState {
.cloned()
{
window.on_commit();
// A game frame, which is what the stats claim to report. Counted
// here rather than on the frame-callback tick: that tick fires
// whether anything was drawn or not, so counting it reported the
// compositor's own cadence back as the game's rate.
self.game_frame_count += 1;
}
}

View File

@@ -15,7 +15,8 @@
//! Options:
//! --width <N> Output width [default: 1920]
//! --height <N> Output height [default: 1080]
//! --fps <N> Virtual refresh rate [default: 60]
//! --fps <N> Virtual refresh rate, advertised only [default: 60]
//! --frame-callback-hz <N> wl_surface.frame cadence [default: 1000]
//! --hdr Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain)
//! --socket <NAME> Wayland socket name [default: nescope-0]
//! ```
@@ -94,10 +95,36 @@ struct Args {
#[arg(long, default_value = "1080", env = "NESCOPE_HEIGHT")]
height: u32,
/// Virtual output refresh rate (fps).
/// Virtual output refresh rate, as advertised to clients.
///
/// **Advertised only — this does not pace anything.** It is what a game
/// reads as its monitor's refresh rate, so it should be the rate the
/// session actually sends at: a game with V-Sync on will lock to it, and
/// one that reads the mode to build a settings list will offer it.
///
/// Pacing is `--frame-callback-hz`, and the two used to be this one value.
/// That made an honest advertisement and a non-binding cadence mutually
/// exclusive, which is why the default sat at 60 while sessions asked for
/// 120.
#[arg(long, default_value = "60", env = "NESCOPE_FPS")]
fps: u32,
/// How often `wl_surface.frame` callbacks are sent, in hertz.
///
/// This is the only rate that can throttle a client, and only a FIFO one:
/// `IMMEDIATE` and `MAILBOX` swapchains ignore these callbacks entirely.
/// It is therefore not a frame limiter — it cannot hold a game whose
/// V-Sync is off, which is every game whose player turned it off. That job
/// belongs to the capture layer, which sees every present and can hold the
/// application whatever its swapchain does.
///
/// So the default is set high enough never to bind, and the compositor
/// stops being a second opinion on the frame rate. The cost is the timer
/// itself: a wakeup per tick, each sending callbacks to the surfaces in
/// the space. Lower it if that shows up on a small box.
#[arg(long, default_value = "1000", env = "NESCOPE_FRAME_CALLBACK_HZ")]
frame_callback_hz: u32,
/// Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain_factory_v2).
#[arg(long, env = "NESCOPE_HDR")]
hdr: bool,
@@ -386,9 +413,17 @@ fn main() {
}
// ── Frame-callback timer ──────────────────────────────────────────────
// Send wl_surface.frame done events at the target fps. This is what
// drives the game's render loop in the absence of a real scanout.
let frame_interval = Duration::from_micros(1_000_000 / args.fps.max(1) as u64);
// Sends wl_surface.frame done events, releases the held buffer and posts
// presentation feedback.
//
// Deliberately *not* `--fps`. This cadence only ever throttles a FIFO
// client, so using it as a frame limiter caps the games that opted into
// V-Sync and does nothing at all to the ones that did not — which is the
// wrong way round, and it capped them at 60 while sessions asked for 120.
// The capture layer holds the game instead, and this runs fast enough to
// stay out of the way.
let frame_interval =
Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64);
loop_handle
.insert_source(Timer::from_duration(frame_interval), move |_, _, data| {
if let Some(ref mut li) = data.libinput {

View File

@@ -178,7 +178,7 @@ pub struct NescopeState {
/// Whether the cursor has been explicitly positioned at least once.
pub cursor_initialized: bool,
/// Game FPS tracking: frame count since last stats send.
game_frame_count: u64,
pub game_frame_count: u64,
/// Last time stats were sent.
last_stats_time: std::time::Instant,
/// Last cursor position sent over IPC (for change detection).
@@ -753,13 +753,16 @@ impl NescopeState {
}
// -----------------------------------------------------------------------
// Frame callbacks — driven by the fps timer in main.rs
// Frame callbacks — driven by the frame-callback timer in main.rs
// -----------------------------------------------------------------------
/// Called from the calloop timer at target fps.
/// Called from the calloop timer at `--frame-callback-hz`.
///
/// Note what this does *not* count: the game's frames. This runs whether or
/// not anything was drawn, so counting ticks here reported the timer's own
/// rate as the game's — true only while the two were the same number, which
/// they no longer are. `game_frame_count` is incremented on commit.
pub fn on_frame_tick(&mut self) {
self.game_frame_count += 1;
let output = self.output.clone();
let now = self.clock.now();