diff --git a/crates/pf-host-config/src/lib.rs b/crates/pf-host-config/src/lib.rs index ffab3527..bdf2f2c8 100644 --- a/crates/pf-host-config/src/lib.rs +++ b/crates/pf-host-config/src/lib.rs @@ -168,6 +168,38 @@ pub struct HostConfig { /// `PUNKTFUNK_ON_DISCONNECT_CMD` — the `client.disconnected` sibling of /// [`Self::on_connect_cmd`]. pub on_disconnect_cmd: Option, + /// `PUNKTFUNK_MAX_FPS` — frame limiter for the GAME. `None` (unset, `0`, or unparseable) = + /// no limit, the default and what every existing host does. + /// + /// This caps how fast the compositor lets the game render; it does **not** touch the session. + /// The client still negotiates and receives its full rate — a 120 Hz session over a game + /// limited to 60 sends 120 frames a second, 60 of them repeats of an unchanged picture, which + /// costs an almost-empty P-frame. That split is the whole point: the game stops rendering + /// frames nobody asked for, and the GPU time it gives up goes to capture and encode instead + /// (and, on a laptop or handheld, to heat and battery). + /// + /// Capping the STREAM instead would be a different and mostly unwanted feature — it hands the + /// client fewer frames than it asked for and saves the game's GPU nothing. + /// + /// Enforced by the compositor, so its reach is whatever that compositor offers. **gamescope** + /// takes it as `--nested-refresh`, the rate it clamps the game to; note that is the nested + /// output's rate, so everything gamescope composites moves at it, not the game alone — under + /// gamescope there is only the one output. Values are clamped into 1..=240. + pub max_fps: Option, + /// `PUNKTFUNK_VDISPLAY_HZ_MULT` — run the VIRTUAL DISPLAY at this multiple of the session's + /// frame rate while the stream stays paced at the session rate. Default 1 (off); 2 is the + /// interesting one, hence the name this shipped under. + /// + /// A compositor only paints on its own vblank, so at 1× a frame can be finished just after + /// the capture sampled and then waits nearly a whole interval to be picked up — up to + /// ~16 ms of pure age at 60 Hz, and it is the jittery part of the latency, not the steady + /// part. Driving the display at 2× halves that worst case without sending a single extra + /// frame: the pacing clamp below keeps the wire at exactly the rate the client negotiated. + /// + /// It is not free — the compositor and the GPU do the extra composites — so it stays opt-in. + /// Clamped to 1..=4; a backend that cannot honor the multiplied rate simply reports what it + /// achieved and the pacing follows that, exactly as it does for any other refusal. + pub vdisplay_hz_mult: u32, } impl HostConfig { @@ -235,6 +267,31 @@ impl HostConfig { .filter(|s| !s.trim().is_empty()), on_connect_cmd: val("PUNKTFUNK_ON_CONNECT_CMD").filter(|s| !s.trim().is_empty()), on_disconnect_cmd: val("PUNKTFUNK_ON_DISCONNECT_CMD").filter(|s| !s.trim().is_empty()), + // 0 means "no limit" rather than "stream nothing" — it is the natural way to spell + // "off" in a config file, and a 0 fps session is not a thing anyone wants. + max_fps: val("PUNKTFUNK_MAX_FPS") + .and_then(|s| s.trim().parse::().ok()) + .filter(|&f| f > 0) + .map(|f| f.clamp(1, 240)), + vdisplay_hz_mult: val("PUNKTFUNK_VDISPLAY_HZ_MULT") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1) + .clamp(1, 4), + } + } +} + +impl HostConfig { + /// The rate to hand the compositor as the GAME's refresh: the session's rate, capped by + /// [`Self::max_fps`]. Only the compositor's game-facing rate goes through here — the session's + /// own mode, the encoder and the wire never do (see the field docs for why). + /// + /// `0` in means `0` out. A zero rate is rejected upstream, and quietly turning it into a real + /// one here would hide that. + pub fn game_fps(&self, session_hz: u32) -> u32 { + match self.max_fps { + Some(cap) if session_hz > cap => cap, + _ => session_hz, } } } @@ -244,3 +301,32 @@ pub fn config() -> &'static HostConfig { static CFG: OnceLock = OnceLock::new(); CFG.get_or_init(HostConfig::from_env) } + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg(max_fps: Option) -> HostConfig { + HostConfig { + max_fps, + ..Default::default() + } + } + + #[test] + fn game_fps_caps_only_above_the_limit() { + // Unset: every session rate passes through untouched — the default, and every existing + // host. The game keeps rendering at the session's rate, exactly as it always did. + for hz in [24, 30, 60, 120, 144, 240] { + assert_eq!(cfg(None).game_fps(hz), hz); + } + // Set: capped above, exact at, untouched below. A session BELOW the limit keeps its own + // rate — the knob is a ceiling on the game, not a target to render up to. + let c = cfg(Some(60)); + assert_eq!(c.game_fps(120), 60); + assert_eq!(c.game_fps(60), 60); + assert_eq!(c.game_fps(30), 30); + // An invalid rate stays invalid rather than being laundered into a real one. + assert_eq!(c.game_fps(0), 0); + } +} diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 6cc579d9..e727e725 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -806,9 +806,30 @@ fn headless_shim_dir() -> std::path::PathBuf { std::path::Path::new(&base).join("punktfunk-gsbin") } +/// The rate to give gamescope as its nested refresh — the client's, capped by the host's frame +/// limiter (`PUNKTFUNK_MAX_FPS`, unset by default). +/// +/// gamescope's nested refresh is the rate the game is clamped to, which is what makes it the right +/// and only place for this knob. The session is untouched: the client still negotiates and receives +/// its full rate, because the encode loop re-encodes the held frame whenever the compositor has +/// produced no new one — a repeat of an unchanged picture, which costs an almost-empty P-frame. +/// So a 60-capped game on a 120 Hz session still puts 120 frames a second on the wire, and the GPU +/// time the game is no longer spending goes to capture and encode instead (see +/// `design/…` and issue #9's contention notes). +/// +/// The cap is the nested output's rate, so everything gamescope composites moves at it, not just +/// the game — under gamescope there is only the one output. That is the trade the knob is: it is +/// off by default, and its whole purpose is to stop the game rendering flat out. +fn game_hz(session_hz: u32) -> u32 { + pf_host_config::config().game_fps(session_hz).max(1) +} + /// The gamescope arg-rewriting shim. SteamOS hardcodes physical-panel args, so we intercept the /// session's `exec gamescope` (via PATH) and rewrite to a headless output at the client's mode (read /// from `PF_W`/`PF_H`/`PF_HZ`), dropping the physical flags. Idempotent; returns the shim's directory. +/// +/// `PF_HZ` is the frame-limited rate ([`game_hz`]) — the shim spends it on `-r` alone, so it caps +/// the game without touching the resolution the client negotiated (`PF_W`/`PF_H`). fn write_headless_shim() -> Result { // `$PF_HDR_ARGS` is unquoted for the same reason as in the GAMESCOPE_BIN wrapper: it is our // own flag list ([`hdr_args`]) and must word-split into separate argv entries. @@ -866,7 +887,7 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re shim = shim_dir.display(), w = mode.width, h = mode.height, - hz = mode.refresh_hz.max(1), + hz = game_hz(mode.refresh_hz), // Read (unquoted) by the PATH shim — empty for an SDR session. Quoted HERE because a // systemd `Environment=` value with spaces must be, or only the first flag survives. hdr_args = hdr_args(hdr) @@ -2152,6 +2173,12 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul let wrapper = write_gamescope_bin_wrapper()?; stop_session(unit_name); // clear any stale unit + relay so a relaunch is clean let hz = mode.refresh_hz.max(1); + // The two rates are deliberately different when the frame limiter is set. CUSTOM_REFRESH_RATES + // generates the mode the session ADVERTISES, which must stay the client's — that is what makes + // games see the real refresh instead of the box's EDID. PF_HZ becomes `--nested-refresh`, the + // rate the game is clamped to, and is the only one the limiter touches. Identical when it's + // unset, which is the default. + let game = game_hz(mode.refresh_hz); let start_unit = || -> Result<()> { let status = Command::new("systemd-run") .args(["--user", "--collect", &format!("--unit={unit_name}")]) @@ -2162,7 +2189,7 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul .arg("--setenv=BACKEND=headless") .arg(format!("--setenv=SCREEN_WIDTH={}", mode.width)) .arg(format!("--setenv=SCREEN_HEIGHT={}", mode.height)) - .arg(format!("--setenv=PF_HZ={hz}")) + .arg(format!("--setenv=PF_HZ={game}")) // Read (unquoted) by the GAMESCOPE_BIN wrapper — empty for a stock-gamescope SDR // session, and carrying the cursor flag whenever the binary supports it. .arg(format!( @@ -2304,6 +2331,10 @@ fn shape_dedicated_command(app: &str) -> String { /// Add the compositor-side arguments shared by every bare gamescope spawn. `steam_mode` belongs /// before the `--` terminator; [`PUNKTFUNK_GAMESCOPE_APP`](spawn) configures the nested command /// after it and therefore cannot enable gamescope's Steam integration itself. +/// +/// `-r` is the rate the GAME sees and is clamped to, which is why the frame limiter lives here +/// (see [`game_hz`]) and nowhere near the session: capping it makes the game stop rendering +/// frames nobody asked for, while capture and the wire keep running at the client's own rate. fn add_bare_gamescope_args( command: &mut Command, w: u32, @@ -2317,7 +2348,7 @@ fn add_bare_gamescope_args( .args(["--backend", "headless"]) .args(["-W", &w.to_string()]) .args(["-H", &h.to_string()]) - .args(["-r", &hz.to_string()]); + .args(["-r", &game_hz(hz).to_string()]); if steam_mode { command.arg("--steam"); } @@ -2514,7 +2545,7 @@ impl Drop for GamescopeProc { mod tests { use super::{ cgroup_is_punktfunk_owned, cgroup_under_user_manager, connected_connector_under, - display_manager_unit_under, dm_survives_masked_unit, hdr_args, is_steam_launch, + display_manager_unit_under, dm_survives_masked_unit, game_hz, hdr_args, is_steam_launch, missing_flags, nested_wrapper_script, sentinel_advanced, shape_dedicated_command, }; @@ -2676,6 +2707,21 @@ mod tests { ); } + #[test] + fn game_hz_is_the_session_rate_until_the_limiter_is_set() { + // The env is process-wide and `config()` is parsed once, so this asserts the DEFAULT + // (nothing set) — which is the case that matters most: every existing host must keep + // handing gamescope the client's own rate, untouched. `game_fps`'s own unit test in + // pf-host-config covers the capping arithmetic without needing the env. + if pf_host_config::config().max_fps.is_none() { + for hz in [30, 60, 120, 144, 240] { + assert_eq!(game_hz(hz), hz); + } + } + // Never zero, whatever the inputs: gamescope would reject `-r 0`. + assert!(game_hz(0) >= 1); + } + #[test] fn desktop_steam_cgroup_ownership() { // A desktop-launched Steam (the B1b conflict case, as observed on a GNOME host). diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index cc5b946f..e1be10c0 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -98,6 +98,8 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | `PUNKTFUNK_PYROWAVE_MAX_MBPS` | `N` (Mbps) | Cap the [PyroWave](/docs/pyrowave) Automatic bitrate pin, for a host on a link that the open-loop pin can outrun (e.g. 4:4:4 + HDR at 5120×1440@240 pins ~5.3 Gbps, over a 5GbE link). Unset = no cap. Only affects Automatic (bitrate `0`) PyroWave sessions; an explicit client bitrate bypasses it. | | `PUNKTFUNK_DSCP` | `1` | Opt-in DSCP / `SO_PRIORITY` QoS tagging on the media sockets. No-op on the wire on Windows without a qWAVE policy. | | `PUNKTFUNK_OH264_THREADS` / `PUNKTFUNK_OH264_GOP` | `N` | Software (openh264) encoder tuning: encode threads (default 2 — latency over throughput) and GOP length (default 0 = encoder-auto). Only relevant with `PUNKTFUNK_ENCODER=software`. | +| `PUNKTFUNK_MAX_FPS` | `N` (fps) *(default: no limit)* | **Frame limiter for the game** — how fast the compositor lets it render. It does *not* cap the stream: the client still negotiates and receives its full rate, because the encode loop re-encodes the held frame whenever the compositor produced no new one (an almost-empty P-frame). A 60-capped game on a 120 Hz session still sends 120 frames a second, and the GPU time the game gives up goes to capture and encode instead — and to heat and battery on a laptop or handheld. **gamescope only today**: it takes this as `--nested-refresh`, the rate it clamps the game to; that is the nested output's rate, so everything gamescope composites moves at it. Other compositors have no equivalent lever and ignore it. | +| `PUNKTFUNK_VDISPLAY_HZ_MULT` | `1`–`4` *(default `1` = off)* | Run the **virtual display** at a multiple of the session's frame rate without sending a single extra frame. A compositor paints on its own vblank, so a frame finished just after the capture sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. At `2` that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in. If the backend won't give the multiplied rate it reports what it achieved and the stream paces to that. | ## Gamepads diff --git a/scripts/host.env.example b/scripts/host.env.example index c51ce127..95393cf5 100644 --- a/scripts/host.env.example +++ b/scripts/host.env.example @@ -88,6 +88,25 @@ PUNKTFUNK_VIDEO_SOURCE=virtual # VAAPI/AMF/QSV decline (4:2:0). GameStream/Moonlight always stays 4:2:0. #PUNKTFUNK_444=1 #PUNKTFUNK_10BIT=1 # HEVC Main10 / HDR (when the client advertises 10-bit) + +# Frame limiter for the GAME — how fast the compositor lets it render. Unset (or 0) = no limit, +# the default. It does NOT cap the stream: the client still negotiates and receives its full rate, +# because the encode loop re-encodes the held frame when the compositor produced no new one (an +# almost-empty P-frame). So a 60-capped game on a 120 Hz session still sends 120 frames a second, +# and the GPU time the game gives up goes to capture and encode instead — and to heat and battery +# on a laptop or handheld. +# gamescope only, today: it takes this as --nested-refresh, the rate it clamps the game to. That is +# the nested output's rate, so everything gamescope composites moves at it. Other compositors have +# no equivalent lever and ignore it. +#PUNKTFUNK_MAX_FPS=60 + +# Run the virtual display at a MULTIPLE of the session's frame rate, without sending a single +# extra frame. A compositor paints on its own vblank, so a frame finished just after the capture +# sampled waits nearly a whole interval to be picked up — the jittery part of the latency budget. +# At 2 that worst case halves. Costs the compositor and GPU the extra composites, so it's opt-in; +# 1 (default) is off, 4 is the ceiling. If the backend won't give the multiplied rate it reports +# what it achieved and the stream paces to that, exactly as it would for any other refusal. +#PUNKTFUNK_VDISPLAY_HZ_MULT=2 #RUST_LOG=info # Management API bearer token. The mgmt API is HTTPS + token-authenticated ALWAYS (even on # loopback); if unset it is auto-generated + persisted to ~/.config/punktfunk/mgmt-token (which the diff --git a/scripts/windows/host.env.example b/scripts/windows/host.env.example index e427d714..f7471eb7 100644 --- a/scripts/windows/host.env.example +++ b/scripts/windows/host.env.example @@ -55,3 +55,9 @@ RUST_LOG=info # Keep a per-client virtual display alive briefly after disconnect so a quick reconnect reuses it # (no display connect/disconnect chime). Default 10000 ms. #PUNKTFUNK_MONITOR_LINGER_MS=10000 + + +# Run the virtual display at a MULTIPLE of the session's frame rate, without sending a single extra +# frame — halves the worst-case wait for a freshly composited frame at 2. Costs the extra +# composites, so it's opt-in; 1 (default) is off, 4 is the ceiling. +#PUNKTFUNK_VDISPLAY_HZ_MULT=2