On a Steam Deck in Gaming Mode, the Steam menu and the QAM are driven by the same physical controller the client forwards. So opening either one steered the game on the host at the same time as Steam's UI — a second, invisible player. Scrolling the QAM walked your character; the A that picked a row also pressed A in the game.
Steam Input masks a normal game in this situation. It cannot mask us: masking happens on Steam Input's virtual pad, and the client deliberately forwards the real one (28DE:1205 — the virtual pad has no gyro, trackpads or paddles). Verified from the client's own log on a Deck: it attaches "Steam Deck Controller" … steam_virtual=false.
Why we don't get this for free
SDL already ships exactly the behaviour we want, on by default — it drops presses while the process has windows but no keyboard focus, and lets releases through so nothing sticks. We never set SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS, and every input path in the client funnels through it (the raw Deck HIDAPI driver calls SDL_SendJoystickButton like everything else).
It structurally cannot fire on a Deck. gamescope resolves focus per Xwayland ctx — determine_and_apply_focus() scans only that ctx's window list — the Steam overlay lives in the root ctx, and the client sits alone in its own. So XSetInputFocus for our ctx keeps focus on our window and no FocusOut is ever generated.
Measured on glass (2026-08-08, gamescope 3.16.23.4), Steam menu and QAM behaving identically:
[23:27:47] app=3856846079 gfx=3856846079 xfocus1=6291503 ← baseline
[23:30:17] app=769 gfx=3856846079 xfocus1=6291503 ← STEAM menu open
0x2400035 "Steam Big Picture Mode" STEAM_OVERLAY=1 STEAM_INPUT_FOCUS=1
[23:30:21] app=3856846079 gfx=3856846079 xfocus1=6291503 ← closed
[23:30:23] app=769 gfx=3856846079 xfocus1=6291503 ← QAM open, same props
xfocus1 — X input focus inside the client's own ctx — never moves. STEAM_INPUT_FOCUS=1, so this is not the "mode 2 keeps keyboard focus on the game" case; gamescope really does move focus, just not anywhere our X server can see.
What this adds
overlay_focus (new, Linux) watches GAMESCOPE_FOCUSED_APP (input focus) against GAMESCOPE_FOCUSED_APP_GFX (displayed app) and publishes one atomic. They diverge exactly while something else owns input. Two wrinkles worth knowing:
The atoms are not on our own $DISPLAY. Gaming Mode runs --xwayland-count 2: Steam and the atoms are on the first server, the app is handed the second. Discovery tries $DISPLAY first (right for a single-server gamescope), then walks /tmp/.X11-unix.
gamescope writes these properties with zero length when the appid is 0, so "present but empty" means no app — reading it as Some(0) would differ from every real appid and mask the pad on an empty home screen. Pinned by a test.
GamepadService::set_masked is deliberately notset_forwarding. Forwarding-off closes the slot and sends GamepadRemove, so the game would see a controller unplug every time somebody opened the QAM — pause menus, "reconnect your controller", player-slot churn. Masking keeps every slot open and only stops the transitions, after flush_slot zeroes what the host believes is held, so a stick deflected at overlay-open stops steering rather than freezing at its last value.
Coming back is asymmetric, and that's the bit most worth a reviewer's eye:
Buttons are adopted, not replayed. The A that picked a QAM row must not fire in the game the instant it closes; release and press again to arm it. Same rule MenuNav::reset already applies across a screen handoff.
Axes are re-sent. A stick has no press semantics to ghost, and SDL only speaks on change, so one still held when the overlay closes would stay dead host-side until the user happened to move it.
The presenter ORs the overlay signal with window focus into a single writer — the first cut had both writing one flag and clearing each other (a focus-loss mask undone by the next poll reporting "no overlay"). Focus loss now also flushes neutral, which closes the same gap on the desktop, where SDL's gate does fire but nothing ever zeroed the held state.
Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it is today. PUNKTFUNK_OVERLAY_MASK=0 opts out.
Checks
In pf-lxcheck2: cargo check / build, clippy --all-targets -- -D warnings and cargo fmt --check clean across pf-client-core, punktfunk-client-session, punktfunk-client-linux; 186 existing tests pass plus 3 new ones. Runs are non-vacuous — earlier iterations surfaced real errors from these files.
x11rb is added with default-features = false, keeping the pure-Rust connection — no libxcb link, no new C dependency on any client package, matching what pf-capture and pf-vdisplay already do.
Two things still owed — please don't merge on my say-so alone
The flatpak line is unverified.--filesystem=/tmp/.X11-unix:ro is needed because flatpak binds only the single socket named by DISPLAY. I could not confirm it doesn't shadow flatpak's own X11 bind — the Deck had switched to Desktop Mode and the sandbox wouldn't take X11 there. If it did shadow, the client would fail to open a window in Gaming Mode. One command in Game Mode settles it:
flatpak run --filesystem=/tmp/.X11-unix:ro --command=sh io.unom.Punktfunk -c 'echo $DISPLAY; ls /tmp/.X11-unix/'
Expect your own X<n> plus the root ctx's socket. The Rust side is unaffected either way — no signal just means it forwards as before.
On-glass for the feature itself. Stream to the Deck, open the QAM, confirm the game stops responding and resumes cleanly on close. Each edge logs overlay input mask at info, so the session's stderr shows it firing.
Behaviour is on by default because matching Steam Input is what a controller user expects; there is no Settings row yet, only the env opt-out. Happy to add one if that's the preference.
On a Steam Deck in Gaming Mode, the Steam menu and the QAM are driven by the **same physical controller the client forwards**. So opening either one steered the game on the host at the same time as Steam's UI — a second, invisible player. Scrolling the QAM walked your character; the A that picked a row also pressed A in the game.
Steam Input masks a normal game in this situation. It cannot mask us: masking happens on Steam Input's *virtual* pad, and the client deliberately forwards the **real** one (`28DE:1205` — the virtual pad has no gyro, trackpads or paddles). Verified from the client's own log on a Deck: it attaches `"Steam Deck Controller" … steam_virtual=false`.
## Why we don't get this for free
SDL already ships exactly the behaviour we want, on by default — it drops presses while the process has windows but no keyboard focus, and lets *releases* through so nothing sticks. We never set `SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS`, and every input path in the client funnels through it (the raw Deck HIDAPI driver calls `SDL_SendJoystickButton` like everything else).
**It structurally cannot fire on a Deck.** gamescope resolves focus *per Xwayland ctx* — `determine_and_apply_focus()` scans only that ctx's window list — the Steam overlay lives in the root ctx, and the client sits alone in its own. So `XSetInputFocus` for our ctx keeps focus on our window and no `FocusOut` is ever generated.
Measured on glass (2026-08-08, gamescope 3.16.23.4), Steam menu and QAM behaving identically:
```
[23:27:47] app=3856846079 gfx=3856846079 xfocus1=6291503 ← baseline
[23:30:17] app=769 gfx=3856846079 xfocus1=6291503 ← STEAM menu open
0x2400035 "Steam Big Picture Mode" STEAM_OVERLAY=1 STEAM_INPUT_FOCUS=1
[23:30:21] app=3856846079 gfx=3856846079 xfocus1=6291503 ← closed
[23:30:23] app=769 gfx=3856846079 xfocus1=6291503 ← QAM open, same props
```
`xfocus1` — X input focus inside the client's own ctx — never moves. `STEAM_INPUT_FOCUS=1`, so this is not the "mode 2 keeps keyboard focus on the game" case; gamescope really does move focus, just not anywhere our X server can see.
## What this adds
**`overlay_focus`** (new, Linux) watches `GAMESCOPE_FOCUSED_APP` (input focus) against `GAMESCOPE_FOCUSED_APP_GFX` (displayed app) and publishes one atomic. They diverge exactly while something else owns input. Two wrinkles worth knowing:
- The atoms are **not on our own `$DISPLAY`**. Gaming Mode runs `--xwayland-count 2`: Steam and the atoms are on the first server, the app is handed the second. Discovery tries `$DISPLAY` first (right for a single-server gamescope), then walks `/tmp/.X11-unix`.
- gamescope writes these properties with **zero length** when the appid is 0, so "present but empty" means *no app* — reading it as `Some(0)` would differ from every real appid and mask the pad on an empty home screen. Pinned by a test.
**`GamepadService::set_masked`** is deliberately **not** `set_forwarding`. Forwarding-off closes the slot and sends `GamepadRemove`, so the game would see a controller **unplug** every time somebody opened the QAM — pause menus, "reconnect your controller", player-slot churn. Masking keeps every slot open and only stops the transitions, after `flush_slot` zeroes what the host believes is held, so a stick deflected at overlay-open stops steering rather than freezing at its last value.
Coming back is asymmetric, and that's the bit most worth a reviewer's eye:
- **Buttons are adopted, not replayed.** The A that picked a QAM row must not fire in the game the instant it closes; release and press again to arm it. Same rule `MenuNav::reset` already applies across a screen handoff.
- **Axes are re-sent.** A stick has no press semantics to ghost, and SDL only speaks on *change*, so one still held when the overlay closes would stay dead host-side until the user happened to move it.
The presenter ORs the overlay signal with window focus into a **single** writer — the first cut had both writing one flag and clearing each other (a focus-loss mask undone by the next poll reporting "no overlay"). Focus loss now also flushes neutral, which closes the same gap on the desktop, where SDL's gate does fire but nothing ever zeroed the held state.
Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it is today. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
## Checks
In `pf-lxcheck2`: `cargo check` / `build`, `clippy --all-targets -- -D warnings` and `cargo fmt --check` clean across `pf-client-core`, `punktfunk-client-session`, `punktfunk-client-linux`; 186 existing tests pass plus 3 new ones. Runs are non-vacuous — earlier iterations surfaced real errors from these files.
`x11rb` is added with `default-features = false`, keeping the pure-Rust connection — no libxcb link, no new C dependency on any client package, matching what `pf-capture` and `pf-vdisplay` already do.
## Two things still owed — please don't merge on my say-so alone
1. **The flatpak line is unverified.** `--filesystem=/tmp/.X11-unix:ro` is needed because flatpak binds only the single socket named by `DISPLAY`. I could not confirm it doesn't shadow flatpak's own X11 bind — the Deck had switched to Desktop Mode and the sandbox wouldn't take X11 there. **If it did shadow, the client would fail to open a window in Gaming Mode.** One command in Game Mode settles it:
```
flatpak run --filesystem=/tmp/.X11-unix:ro --command=sh io.unom.Punktfunk -c 'echo $DISPLAY; ls /tmp/.X11-unix/'
```
Expect your own `X<n>` plus the root ctx's socket. The Rust side is unaffected either way — no signal just means it forwards as before.
2. **On-glass for the feature itself.** Stream to the Deck, open the QAM, confirm the game stops responding and resumes cleanly on close. Each edge logs `overlay input mask` at info, so the session's stderr shows it firing.
Behaviour is on by default because matching Steam Input is what a controller user expects; there is no Settings row yet, only the env opt-out. Happy to add one if that's the preference.
On a Deck in Gaming Mode the Steam menu and the QAM are driven by the SAME
physical controller the client forwards, so opening either one moved the game
on the host as well as Steam's UI — a second, invisible player. Steam Input
masks a normal game here; it cannot mask us, because masking happens on Steam
Input's virtual pad and we deliberately forward the REAL one (28DE:1205 — the
virtual pad has no gyro, trackpads or paddles).
SDL ships the exact behaviour we want and it is on by default: presses are
dropped while the process has windows but no keyboard focus, releases still get
through. It CANNOT fire on a Deck. gamescope resolves focus per Xwayland ctx
and the client sits alone in its own, so the Steam overlay — which lives in the
root ctx — never takes our X focus away and no FocusOut is ever generated.
Measured on glass: with the QAM open, X input focus inside the client's ctx
stayed on its window for the whole 4 s, while GAMESCOPE_FOCUSED_APP flipped to
769 (Steam) and GAMESCOPE_FOCUSED_APP_GFX stayed on the app.
So the signal is explicit. `overlay_focus` watches those two atoms on the
gamescope root ctx — which is NOT our own $DISPLAY under `--xwayland-count 2`,
hence the socket-directory walk and the flatpak filesystem line — and the
presenter ORs it with window focus into one `set_masked`.
Masking is deliberately not `set_forwarding`: that closes the slot and sends
GamepadRemove, so the game would see a controller UNPLUG every time somebody
opened the QAM. This keeps every slot open and only stops the transitions,
after flushing what the host believes is held so a stick deflected at
overlay-open stops steering instead of freezing at its last value. On the way
back, held buttons are adopted rather than replayed — the A that picked a QAM
row must not fire in the game as it closes — while axes are re-sent, since a
stick has no press to ghost and SDL only speaks on change.
Fails open throughout: no gamescope, no X, or an unreadable signal all leave
forwarding exactly as it was. `PUNKTFUNK_OVERLAY_MASK=0` opts out.
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.
On a Steam Deck in Gaming Mode, the Steam menu and the QAM are driven by the same physical controller the client forwards. So opening either one steered the game on the host at the same time as Steam's UI — a second, invisible player. Scrolling the QAM walked your character; the A that picked a row also pressed A in the game.
Steam Input masks a normal game in this situation. It cannot mask us: masking happens on Steam Input's virtual pad, and the client deliberately forwards the real one (
28DE:1205— the virtual pad has no gyro, trackpads or paddles). Verified from the client's own log on a Deck: it attaches"Steam Deck Controller" … steam_virtual=false.Why we don't get this for free
SDL already ships exactly the behaviour we want, on by default — it drops presses while the process has windows but no keyboard focus, and lets releases through so nothing sticks. We never set
SDL_JOYSTICK_ALLOW_BACKGROUND_EVENTS, and every input path in the client funnels through it (the raw Deck HIDAPI driver callsSDL_SendJoystickButtonlike everything else).It structurally cannot fire on a Deck. gamescope resolves focus per Xwayland ctx —
determine_and_apply_focus()scans only that ctx's window list — the Steam overlay lives in the root ctx, and the client sits alone in its own. SoXSetInputFocusfor our ctx keeps focus on our window and noFocusOutis ever generated.Measured on glass (2026-08-08, gamescope 3.16.23.4), Steam menu and QAM behaving identically:
xfocus1— X input focus inside the client's own ctx — never moves.STEAM_INPUT_FOCUS=1, so this is not the "mode 2 keeps keyboard focus on the game" case; gamescope really does move focus, just not anywhere our X server can see.What this adds
overlay_focus(new, Linux) watchesGAMESCOPE_FOCUSED_APP(input focus) againstGAMESCOPE_FOCUSED_APP_GFX(displayed app) and publishes one atomic. They diverge exactly while something else owns input. Two wrinkles worth knowing:$DISPLAY. Gaming Mode runs--xwayland-count 2: Steam and the atoms are on the first server, the app is handed the second. Discovery tries$DISPLAYfirst (right for a single-server gamescope), then walks/tmp/.X11-unix.Some(0)would differ from every real appid and mask the pad on an empty home screen. Pinned by a test.GamepadService::set_maskedis deliberately notset_forwarding. Forwarding-off closes the slot and sendsGamepadRemove, so the game would see a controller unplug every time somebody opened the QAM — pause menus, "reconnect your controller", player-slot churn. Masking keeps every slot open and only stops the transitions, afterflush_slotzeroes what the host believes is held, so a stick deflected at overlay-open stops steering rather than freezing at its last value.Coming back is asymmetric, and that's the bit most worth a reviewer's eye:
MenuNav::resetalready applies across a screen handoff.The presenter ORs the overlay signal with window focus into a single writer — the first cut had both writing one flag and clearing each other (a focus-loss mask undone by the next poll reporting "no overlay"). Focus loss now also flushes neutral, which closes the same gap on the desktop, where SDL's gate does fire but nothing ever zeroed the held state.
Fails open throughout: no gamescope, no X, or an unreadable signal all leave forwarding exactly as it is today.
PUNKTFUNK_OVERLAY_MASK=0opts out.Checks
In
pf-lxcheck2:cargo check/build,clippy --all-targets -- -D warningsandcargo fmt --checkclean acrosspf-client-core,punktfunk-client-session,punktfunk-client-linux; 186 existing tests pass plus 3 new ones. Runs are non-vacuous — earlier iterations surfaced real errors from these files.x11rbis added withdefault-features = false, keeping the pure-Rust connection — no libxcb link, no new C dependency on any client package, matching whatpf-captureandpf-vdisplayalready do.Two things still owed — please don't merge on my say-so alone
The flatpak line is unverified.
--filesystem=/tmp/.X11-unix:rois needed because flatpak binds only the single socket named byDISPLAY. I could not confirm it doesn't shadow flatpak's own X11 bind — the Deck had switched to Desktop Mode and the sandbox wouldn't take X11 there. If it did shadow, the client would fail to open a window in Gaming Mode. One command in Game Mode settles it:Expect your own
X<n>plus the root ctx's socket. The Rust side is unaffected either way — no signal just means it forwards as before.On-glass for the feature itself. Stream to the Deck, open the QAM, confirm the game stops responding and resumes cleanly on close. Each edge logs
overlay input maskat info, so the session's stderr shows it firing.Behaviour is on by default because matching Steam Input is what a controller user expects; there is no Settings row yet, only the env opt-out. Happy to add one if that's the preference.