Field report (2026-08-22, host 0.32.0-0.ci15055.gca2ff709.fc44, Steam Deck client): one Lutris title capped at ~10 Mbps and looked stuttery, while Steam titles on the same host and client were fine.
What the logs proved
The host was not clamping — every mid-stream bitrate change requested in that session has resolved_kbps == requested_kbps, and the Steam sessions climbed to exactly 331776 kbps, which is stream_ceiling_kbps(2560,1440,120,H265,8,4:2:0). The controller was healthy there.
The Lutris session took one slow-start step to 29 Mbps, hit a backoff ~62 s in while the game was still loading (which ends slow start for good), then ratcheted 57 → 5 Mbps over ten minutes and sawed at the floor for the remaining 25.
Everything loud was ruled out from the host log alone:
No loss — adaptive FEC settled at fec_pct=1 and never adjusted again for 37 minutes.
No flush, no keyframe asks — every jump-to-live sets flush_in_window in the same block that sends CtrlRequest::Keyframe, logged host-side as client requested keyframe (decode recovery). Zero in the session.
Not the decoder — the same Deck held 331 Mbps at 1440p120 with no backoffs at all.
That leaves the host-encode down-driver, and the host said so itself:
A 32 fps source in a 60 Hz session: the game (AC4 via Wine) saturates the GPU, and encode_us — stamped submit → AU-complete — absorbs that contention.
The two defects
1. The thresholds were absolute durations calibrated at 120 Hz.ENCODE_RISE_US is documented as "~half a 120 Hz frame budget" and ENCODE_SEVERE_US as "≈1.5 × a 120 Hz budget". At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup clears the severe tier and takes the immediate ×0.7 — where the identical hiccup at 120 Hz (8.3 ms) never reaches it. That is precisely the 60-vs-120 asymmetry in the field log.
Now sized in the session's own frame budget, plumbed from the negotiated refresh and re-read on a mode switch. Unset (the mode never plumbed in) keeps the old constants exactly.
2. The signal fired for a cause the rate cannot fix. It exists to find the encoder's compute knee, where cutting the rate cuts the work. When encode time is held up by something else on the GPU that premise is false: the backoff changes nothing, the signal fires again, and on_ack's baseline re-seed erases the evidence that nothing improved — so the controller ratchets to the floor pulling the one lever that cannot work.
Now each encode-attributed backoff remembers the level it fired at. Two in a row that fire no lower mean the rate is not the lever, and the signal stands down. Two rather than one because a single pair at a similar level is also what a real knee looks like while the rate is still above it. Windows carrying loss, a flush or a dropped frame never count toward it: cutting the rate genuinely is the remedy for those.
The stand-down is not permanent
Second commit, after review pushback — and the pushback was right. Nothing this controller learns from evidence is permanent: both learned caps re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and NOOP_CLOCK_FLUSHES_TO_DISARM was itself changed from "off for the rest of the session" to re-armable for exactly this reason. The encode stand-down had no business being the exception, and my first pass cited that detector as precedent for the opposite of what it says.
What it answers is transient by nature — GPU contention ends when the game exits to a menu, when the shader storm finishes. What it silences is the only signal that can descend when the encoder is genuinely past its knee on a link that shows nothing else. A permanent stand-down therefore trades a bounded problem for an unbounded one.
So a clean run re-arms it, and the interval doubles each time the silence is immediately re-earned. The asymmetry is the whole argument: a too-eager re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the protection outright.
Loss, OWD, decode and keyframe signals keep their full power throughout, and the host's own climb refusal stays the backstop for a genuine knee. on_mode_switch clears the whole thing like every other mode-scoped piece of learned state.
Tests
Seven new, alongside the 47 existing ABR tests (488 crate tests green, clippy clean):
the_encode_thresholds_follow_the_session_frame_budget — same excursion, severe at 120 Hz, ordinary-and-confirmed at 60 Hz.
unactuatable_encode_rises_disarm_the_down_driver — the field ratchet, and the climb back out afterwards.
an_encode_backoff_that_helps_keeps_the_down_driver_armed — a real knee must not stand the signal down.
a_network_driven_backoff_breaks_the_encode_streak — a flush in an encode-elevated window resets rather than advances.
a_stood_down_encode_signal_re_arms_after_a_clean_run — including that it drives again afterwards.
a_standing_contention_backs_the_re_arm_clock_off — the doubling ladder.
a_bad_window_restarts_the_re_arm_run — the re-probe wants a genuinely quiet stretch.
Not covered here
Threshold scaling follows the session refresh, not the rate the source actually delivers. A game rendering below refresh stretches the real budget further (the host stretches its own deadline by exactly that, cadence_budget), so a sub-refresh source can still present a one-frame hiccup above the severe tier — that residue is what the stand-down is for. Chasing it client-side would mean inferring the source period from arrival cadence, which is the same jitter the signal is trying to read through.
Workaround for the reporter meanwhile: an explicit bitrate for that title disables the controller outright.
Field report (2026-08-22, host `0.32.0-0.ci15055.gca2ff709.fc44`, Steam Deck client): one Lutris title capped at ~10 Mbps and looked stuttery, while Steam titles on the same host and client were fine.
## What the logs proved
The host was not clamping — every `mid-stream bitrate change requested` in that session has `resolved_kbps == requested_kbps`, and the Steam sessions climbed to exactly **331776 kbps**, which is `stream_ceiling_kbps(2560,1440,120,H265,8,4:2:0)`. The controller was healthy there.
The Lutris session took one slow-start step to 29 Mbps, hit a backoff ~62 s in while the game was still loading (which ends slow start for good), then ratcheted **57 → 5 Mbps over ten minutes** and sawed at the floor for the remaining 25.
Everything loud was ruled out from the host log alone:
- **No loss** — adaptive FEC settled at `fec_pct=1` and never adjusted again for 37 minutes.
- **No flush, no keyframe asks** — every jump-to-live sets `flush_in_window` in the same block that sends `CtrlRequest::Keyframe`, logged host-side as `client requested keyframe (decode recovery)`. Zero in the session.
- **Not the decoder** — the same Deck held 331 Mbps at 1440p120 with no backoffs at all.
That leaves the host-encode down-driver, and the host said so itself:
```
encode behind cadence budget_us=31130 interval_us=16666 src_period_us=31130
```
A 32 fps source in a 60 Hz session: the game (AC4 via Wine) saturates the GPU, and `encode_us` — stamped `submit → AU-complete` — absorbs that contention.
## The two defects
**1. The thresholds were absolute durations calibrated at 120 Hz.** `ENCODE_RISE_US` is documented as "~half a 120 Hz frame budget" and `ENCODE_SEVERE_US` as "≈1.5 × a 120 Hz budget". At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup clears the *severe* tier and takes the immediate ×0.7 — where the identical hiccup at 120 Hz (8.3 ms) never reaches it. That is precisely the 60-vs-120 asymmetry in the field log.
Now sized in the session's own frame budget, plumbed from the **negotiated** refresh and re-read on a mode switch. Unset (the mode never plumbed in) keeps the old constants exactly.
**2. The signal fired for a cause the rate cannot fix.** It exists to find the encoder's *compute knee*, where cutting the rate cuts the work. When encode time is held up by something else on the GPU that premise is false: the backoff changes nothing, the signal fires again, and `on_ack`'s baseline re-seed erases the evidence that nothing improved — so the controller ratchets to the floor pulling the one lever that cannot work.
Now each encode-attributed backoff remembers the level it fired at. Two in a row that fire no lower mean the rate is not the lever, and the signal **stands down**. Two rather than one because a single pair at a similar level is also what a real knee looks like while the rate is still above it. Windows carrying loss, a flush or a dropped frame never count toward it: cutting the rate genuinely is the remedy for those.
## The stand-down is not permanent
Second commit, after review pushback — and the pushback was right. Nothing this controller learns from evidence is permanent: both learned caps re-probe on the `CAP_REPROBE_WINDOWS_MIN` ladder, and `NOOP_CLOCK_FLUSHES_TO_DISARM` was itself *changed* from "off for the rest of the session" to re-armable for exactly this reason. The encode stand-down had no business being the exception, and my first pass cited that detector as precedent for the opposite of what it says.
What it answers is transient by nature — GPU contention ends when the game exits to a menu, when the shader storm finishes. What it silences is the only signal that can descend when the encoder is genuinely past its knee on a link that shows nothing else. A permanent stand-down therefore trades a bounded problem for an unbounded one.
So a clean run re-arms it, and the interval doubles each time the silence is immediately re-earned. The asymmetry is the whole argument: a too-eager re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the protection outright.
Loss, OWD, decode and keyframe signals keep their full power throughout, and the host's own climb refusal stays the backstop for a genuine knee. `on_mode_switch` clears the whole thing like every other mode-scoped piece of learned state.
## Tests
Seven new, alongside the 47 existing ABR tests (488 crate tests green, clippy clean):
- `the_encode_thresholds_follow_the_session_frame_budget` — same excursion, severe at 120 Hz, ordinary-and-confirmed at 60 Hz.
- `unactuatable_encode_rises_disarm_the_down_driver` — the field ratchet, and the climb back out afterwards.
- `an_encode_backoff_that_helps_keeps_the_down_driver_armed` — a real knee must not stand the signal down.
- `a_network_driven_backoff_breaks_the_encode_streak` — a flush in an encode-elevated window resets rather than advances.
- `a_stood_down_encode_signal_re_arms_after_a_clean_run` — including that it drives again afterwards.
- `a_standing_contention_backs_the_re_arm_clock_off` — the doubling ladder.
- `a_bad_window_restarts_the_re_arm_run` — the re-probe wants a genuinely quiet stretch.
## Not covered here
Threshold scaling follows the **session refresh**, not the rate the source actually delivers. A game rendering below refresh stretches the real budget further (the host stretches its own deadline by exactly that, `cadence_budget`), so a sub-refresh source can still present a one-frame hiccup above the severe tier — that residue is what the stand-down is for. Chasing it client-side would mean inferring the source period from arrival cadence, which is the same jitter the signal is trying to read through.
Workaround for the reporter meanwhile: an explicit bitrate for that title disables the controller outright.
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that
never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the
host granting every rate it was asked for. Two things did it, both in the
client's encode down-driver.
The thresholds were absolute durations calibrated at 120 Hz ("~half a frame
budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary
one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7
where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the
asymmetry the field log shows against 1440p120 sessions on the same host and
client. Size them in the session's own frame budget instead, plumbed from the
negotiated refresh and re-read on a mode switch.
And the signal fired for a cause the rate cannot fix. It exists to find the
encoder's compute knee, where cutting the rate cuts the work; when encode time
is held up by a game saturating the GPU that premise is false, the backoff
changes nothing, and `on_ack`'s baseline re-seed erases the evidence that
nothing improved. So remember the level each encode-attributed backoff fires at:
two in a row that fire no lower mean the rate is not the lever, and the signal
disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD,
decode and keyframe signals keep their full power throughout, and the host's own
climb refusal stays the backstop for a genuine knee.
Nothing this controller learns from evidence is permanent — both learned caps
re-probe on the CAP_REPROBE_WINDOWS_MIN ladder, and the clock-flush detector was
itself changed from "off for the rest of the session" to re-armable for exactly
this reason. The encode stand-down had no business being the exception.
What it answers is transient by nature: contention on the host's GPU ends when
the game exits to a menu, when the shader storm finishes, when the second app on
the card closes. What it silences is the only signal that can descend when the
encoder is genuinely past its compute knee on a link that shows nothing else. So
a permanent stand-down trades a bounded problem for an unbounded one — one
contended stretch would strip knee protection from every calm minute that
follows, including the ones where a climb can reach a rate the ASIC cannot hold.
A clean run now re-arms it, and the interval doubles each time the silence is
immediately re-earned, so a genuinely standing contention settles into a slow
poll rather than thrashing. The asymmetry is the whole argument: a too-eager
re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the
protection outright.
enricobuehler
marked the pull request as ready for review 2026-08-24 16:06:06 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Field report (2026-08-22, host
0.32.0-0.ci15055.gca2ff709.fc44, Steam Deck client): one Lutris title capped at ~10 Mbps and looked stuttery, while Steam titles on the same host and client were fine.What the logs proved
The host was not clamping — every
mid-stream bitrate change requestedin that session hasresolved_kbps == requested_kbps, and the Steam sessions climbed to exactly 331776 kbps, which isstream_ceiling_kbps(2560,1440,120,H265,8,4:2:0). The controller was healthy there.The Lutris session took one slow-start step to 29 Mbps, hit a backoff ~62 s in while the game was still loading (which ends slow start for good), then ratcheted 57 → 5 Mbps over ten minutes and sawed at the floor for the remaining 25.
Everything loud was ruled out from the host log alone:
fec_pct=1and never adjusted again for 37 minutes.flush_in_windowin the same block that sendsCtrlRequest::Keyframe, logged host-side asclient requested keyframe (decode recovery). Zero in the session.That leaves the host-encode down-driver, and the host said so itself:
A 32 fps source in a 60 Hz session: the game (AC4 via Wine) saturates the GPU, and
encode_us— stampedsubmit → AU-complete— absorbs that contention.The two defects
1. The thresholds were absolute durations calibrated at 120 Hz.
ENCODE_RISE_USis documented as "~half a 120 Hz frame budget" andENCODE_SEVERE_USas "≈1.5 × a 120 Hz budget". At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup clears the severe tier and takes the immediate ×0.7 — where the identical hiccup at 120 Hz (8.3 ms) never reaches it. That is precisely the 60-vs-120 asymmetry in the field log.Now sized in the session's own frame budget, plumbed from the negotiated refresh and re-read on a mode switch. Unset (the mode never plumbed in) keeps the old constants exactly.
2. The signal fired for a cause the rate cannot fix. It exists to find the encoder's compute knee, where cutting the rate cuts the work. When encode time is held up by something else on the GPU that premise is false: the backoff changes nothing, the signal fires again, and
on_ack's baseline re-seed erases the evidence that nothing improved — so the controller ratchets to the floor pulling the one lever that cannot work.Now each encode-attributed backoff remembers the level it fired at. Two in a row that fire no lower mean the rate is not the lever, and the signal stands down. Two rather than one because a single pair at a similar level is also what a real knee looks like while the rate is still above it. Windows carrying loss, a flush or a dropped frame never count toward it: cutting the rate genuinely is the remedy for those.
The stand-down is not permanent
Second commit, after review pushback — and the pushback was right. Nothing this controller learns from evidence is permanent: both learned caps re-probe on the
CAP_REPROBE_WINDOWS_MINladder, andNOOP_CLOCK_FLUSHES_TO_DISARMwas itself changed from "off for the rest of the session" to re-armable for exactly this reason. The encode stand-down had no business being the exception, and my first pass cited that detector as precedent for the opposite of what it says.What it answers is transient by nature — GPU contention ends when the game exits to a menu, when the shader storm finishes. What it silences is the only signal that can descend when the encoder is genuinely past its knee on a link that shows nothing else. A permanent stand-down therefore trades a bounded problem for an unbounded one.
So a clean run re-arms it, and the interval doubles each time the silence is immediately re-earned. The asymmetry is the whole argument: a too-eager re-arm costs one ×0.7 and stands down again; a too-permanent silence costs the protection outright.
Loss, OWD, decode and keyframe signals keep their full power throughout, and the host's own climb refusal stays the backstop for a genuine knee.
on_mode_switchclears the whole thing like every other mode-scoped piece of learned state.Tests
Seven new, alongside the 47 existing ABR tests (488 crate tests green, clippy clean):
the_encode_thresholds_follow_the_session_frame_budget— same excursion, severe at 120 Hz, ordinary-and-confirmed at 60 Hz.unactuatable_encode_rises_disarm_the_down_driver— the field ratchet, and the climb back out afterwards.an_encode_backoff_that_helps_keeps_the_down_driver_armed— a real knee must not stand the signal down.a_network_driven_backoff_breaks_the_encode_streak— a flush in an encode-elevated window resets rather than advances.a_stood_down_encode_signal_re_arms_after_a_clean_run— including that it drives again afterwards.a_standing_contention_backs_the_re_arm_clock_off— the doubling ladder.a_bad_window_restarts_the_re_arm_run— the re-probe wants a genuinely quiet stretch.Not covered here
Threshold scaling follows the session refresh, not the rate the source actually delivers. A game rendering below refresh stretches the real budget further (the host stretches its own deadline by exactly that,
cadence_budget), so a sub-refresh source can still present a one-frame hiccup above the severe tier — that residue is what the stand-down is for. Chasing it client-side would mean inferring the source period from arrival cadence, which is the same jitter the signal is trying to read through.Workaround for the reporter meanwhile: an explicit bitrate for that title disables the controller outright.
A 1440p60 Lutris session ratcheted 57 → 5 Mbps over ten minutes on a link that never dropped a packet — no keyframe asks, no flushes, a flat decoder, and the host granting every rate it was asked for. Two things did it, both in the client's encode down-driver. The thresholds were absolute durations calibrated at 120 Hz ("~half a frame budget", "≈1.5 × a frame budget"). At 60 Hz one frame is 16.7 ms, so an ordinary one-frame encode hiccup cleared the SEVERE tier and took the immediate ×0.7 where the same hiccup at 120 Hz (8.3 ms) never reached it — which is exactly the asymmetry the field log shows against 1440p120 sessions on the same host and client. Size them in the session's own frame budget instead, plumbed from the negotiated refresh and re-read on a mode switch. And the signal fired for a cause the rate cannot fix. It exists to find the encoder's compute knee, where cutting the rate cuts the work; when encode time is held up by a game saturating the GPU that premise is false, the backoff changes nothing, and `on_ack`'s baseline re-seed erases the evidence that nothing improved. So remember the level each encode-attributed backoff fires at: two in a row that fire no lower mean the rate is not the lever, and the signal disarms for the session. Same shape as NOOP_CLOCK_FLUSHES_TO_DISARM. Loss, OWD, decode and keyframe signals keep their full power throughout, and the host's own climb refusal stays the backstop for a genuine knee.