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:
2026-07-28 19:14:10 +02:00
co-authored by Claude Opus 5
parent 86b605f01f
commit eb4ab91627
5 changed files with 163 additions and 4 deletions
+19
View File
@@ -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
+6
View File
@@ -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