feat(host): a frame limiter for the game, not for the stream
PUNKTFUNK_MAX_FPS caps how fast the compositor lets the game render. It deliberately does NOT touch the session: 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, and a repeat of an unchanged picture is 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 gives up goes to capture and encode instead, which is the point (on a laptop or a handheld, it goes to heat and battery too). Capping the stream 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. gamescope is the compositor that has the lever, so it is the one that gets it: the rate becomes --nested-refresh, which is what gamescope clamps the game to. All three sessions we own take it — the bare spawn's -r, the managed gamescope-session-plus wrapper's PF_HZ, and the SteamOS PATH shim's. In the managed path the limit lands on PF_HZ alone and NOT on CUSTOM_REFRESH_RATES, so the mode the session advertises stays the client's — that is what makes games see the real refresh rather than the box's EDID. Two scoping notes. Under gamescope the cap is the nested output's rate, so everything it composites moves at it, not the game alone; there is only the one output. And the attach path (PUNKTFUNK_GAMESCOPE_NODE) mirrors a gamescope we do not own, so it has no lever to pull and is untouched. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<String>,
|
||||
/// `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<u32>,
|
||||
/// `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::<u32>().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::<u32>().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<HostConfig> = OnceLock::new();
|
||||
CFG.get_or_init(HostConfig::from_env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(max_fps: Option<u32>) -> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<std::path::PathBuf> {
|
||||
// `$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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user