Opening the Steam menu on the Deck moved the game too — the pad is now held neutral while an overlay owns it #131

Merged
enricobuehler merged 1 commits from worktree-deck-overlay-input-mask into main 2026-08-08 22:59:31 +00:00
Owner

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 ctxdetermine_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 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.
enricobuehler added 1 commit 2026-08-08 22:58:34 +00:00
feat(client/pads): stop forwarding the pad while the Steam overlay owns it
ci / bun-nix (pull_request) Successful in 32s
ci / docs-site (pull_request) Successful in 1m21s
ci / web (pull_request) Successful in 1m37s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m19s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m37s
ci / rust-arm64 (pull_request) Successful in 5m50s
android / android (pull_request) Successful in 7m56s
ci / rust (pull_request) Successful in 15m55s
nix / flake (pull_request) Failing after 16m33s
5cbaca7789
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.
enricobuehler merged commit 2dd65bdd41 into main 2026-08-08 22:59:31 +00:00
enricobuehler deleted branch worktree-deck-overlay-input-mask 2026-08-08 22:59:32 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#131