fix(input): a German keyboard typed US characters — nothing carried the box's layout into the session

Reported from an iPad on a Bazzite host in Game Mode: `#` arrived as `\`, `-` as `/`, `ä` as `'`.
That set is not random — they are the US ANSI keys that sit where the German ISO ones do, which
says the client was right and the host session was resolving positions with a US keymap.

The key wire is US-POSITIONAL by design: a client sends the physical key, `vk_to_evdev` turns it
into an evdev code, and the session's keymap picks the character. So the contract is "host layout
== the layout on the client's keyboard", and nothing was upholding it:

- Nothing exports `XKB_DEFAULT_*`. `localectl set-x11-keymap de` writes
  /etc/X11/xorg.conf.d/00-keyboard.conf — a file only Xorg reads — and libxkbcommon's fallback
  chain stops at the env vars, which no session manager sets. Compiling from empty names on a
  properly-configured German box therefore yielded evdev/pc105/us, silently.
- gamescope reads those env vars and then publishes the keymap to nobody. It builds the keymap
  onto `keyboard_group`, but the seat carries `virtual_keyboard_device` — a stub whose own comment
  says it exists "only to set the keymap" and which never gets one. `wlserver_keyboardfocus()`
  rebinds that stub on every focus change, and the real group only reaches the seat from a libinput
  key event, which a `--backend headless` session never has. Verified on the box: both Xwayland
  servers map evdev 53/40/43 to slash/apostrophe/backslash on a de/nodeadkeys machine.

`pf_host_config::layout` resolves what the box actually recorded (`XKB_DEFAULT_*`, then
xorg.conf.d, then vconsole's XKBLAYOUT — never vconsole's KEYMAP, whose names are console names and
do not map onto xkb's). From there:

- the wlroots injector compiles its uploaded keymap from it instead of from empty names, which
  fixes Sway/Hyprland hosts outright;
- all four gamescope launch paths hand the session `XKB_DEFAULT_*`, gated behind a `+pfhdr8` probe
  that warns when the binary predates the fix rather than leaving it unexplained;
- `sync_session_keyboard_layout()` covers the case none of that reaches — the autologin session
  punktfunk ATTACHES to, where no launch-time decision applies — by pointing each gamescope
  Xwayland at the box's layout on adoption. Everything in Game Mode is an X11 client of those
  servers, so this is the leg that fixes the report. `PUNKTFUNK_SESSION_LAYOUT=0` turns it off.

gamescope patch 0010 sets the keymap on the stub as well, which is what makes the env legs mean
anything for Wayland-native clients. It is NOT build-verified (no gamescope build environment
here); the series does apply `git am`-clean at the pinned 5fb8dce4.

Verified: 10 new unit tests over the resolver; pf-inject + pf-host-config clippy `-D warnings` and
those tests on real linux-gnu in the CI image; pf-vdisplay checks and clippies clean on linux-gnu
via scripts/xcheck.sh, confirmed non-vacuous with a planted error. The character mapping itself was
confirmed on the box with `xmodmap -pke` before and after applying the layout.
This commit is contained in:
2026-08-15 20:25:46 +02:00
parent 4ee095220f
commit 94be547da0
9 changed files with 726 additions and 13 deletions
+425
View File
@@ -0,0 +1,425 @@
//! Where the host session's keyboard LAYOUT comes from.
//!
//! punktfunk's key wire is **US-positional**: a client sends the Windows VK of the *physical* key
//! it saw, [`vk_to_evdev`](super::keymap::vk_to_evdev) turns that into a Linux evdev code, and the
//! **session's keymap** is what decides which character that position finally produces. The
//! standing contract is therefore "host layout == the layout printed on the client's keyboard" —
//! a German keyboard needs a German session, or its ISO keys render as their US neighbours
//! (`#`→`\`, `ä`→`'`, `-`→`/`, the y↔z swap).
//!
//! Nothing on a Wayland box arranges that by itself. `localectl set-x11-keymap de` records the
//! choice in `/etc/X11/xorg.conf.d/00-keyboard.conf` (and, on systemd ≥ 249, `/etc/vconsole.conf`),
//! but that file is read by **Xorg** — a Wayland compositor never opens it. libxkbcommon's own
//! fallback chain stops at the `XKB_DEFAULT_*` env vars, and no session manager exports those. So
//! compiling a keymap from empty names on a properly-configured German box still silently yields
//! evdev/pc105/**us**, which is exactly the scramble above.
//!
//! [`system_layout`] reads what the machine actually recorded so the injected keyboard follows the
//! box. It is advisory for backends whose keymap we do not own (libei/KWin/gamescope resolve our
//! evdev codes against the compositor's own keymap — see [`crate::text_input_supported`]); there it
//! only feeds the diagnostic, and the operator has to fix the session itself.
use std::path::{Path, PathBuf};
/// The five xkb rule names, each `None` when nothing configured it (libxkbcommon then applies its
/// own built-in default — `evdev`/`pc105`/`us`/``/``).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct XkbNames {
pub rules: Option<String>,
pub model: Option<String>,
pub layout: Option<String>,
pub variant: Option<String>,
pub options: Option<String>,
}
impl XkbNames {
/// Nothing at all was configured ⇒ the compiled keymap will be libxkbcommon's US default.
pub fn is_empty(&self) -> bool {
self.rules.is_none()
&& self.model.is_none()
&& self.layout.is_none()
&& self.variant.is_none()
&& self.options.is_none()
}
/// Fill every field this one leaves unset from `fallback` (per-field precedence, mirroring how
/// libxkbcommon itself falls back one `XKB_DEFAULT_*` variable at a time rather than taking a
/// source whole — a box that exports only `XKB_DEFAULT_LAYOUT` still keeps its configured
/// variant).
fn fill_from(&mut self, fallback: XkbNames) {
for (slot, value) in [
(&mut self.rules, fallback.rules),
(&mut self.model, fallback.model),
(&mut self.layout, fallback.layout),
(&mut self.variant, fallback.variant),
(&mut self.options, fallback.options),
] {
if slot.is_none() {
*slot = value;
}
}
}
/// The names as `xkb_keymap_new_from_names` wants them: an empty string means "unset", which
/// is where libxkbcommon applies its own default for that field.
pub fn as_args(&self) -> (&str, &str, &str, &str, Option<String>) {
(
self.rules.as_deref().unwrap_or(""),
self.model.as_deref().unwrap_or(""),
self.layout.as_deref().unwrap_or(""),
self.variant.as_deref().unwrap_or(""),
self.options.clone(),
)
}
/// The same names as `XKB_DEFAULT_*` environment pairs, for a compositor punktfunk spawns
/// itself. Only the fields we actually resolved are emitted — exporting an empty
/// `XKB_DEFAULT_VARIANT` is not the same as leaving it unset, since an explicit empty string
/// *overrides* a variant the rules file would otherwise supply.
pub fn env_pairs(&self) -> Vec<(&'static str, String)> {
[
("XKB_DEFAULT_RULES", &self.rules),
("XKB_DEFAULT_MODEL", &self.model),
("XKB_DEFAULT_LAYOUT", &self.layout),
("XKB_DEFAULT_VARIANT", &self.variant),
("XKB_DEFAULT_OPTIONS", &self.options),
]
.into_iter()
.filter_map(|(k, v)| v.as_ref().map(|v| (k, v.clone())))
.collect()
}
/// `de(nodeadkeys)` / `de` / `us (libxkbcommon default)` — for one readable log field.
pub fn describe(&self) -> String {
match (&self.layout, &self.variant) {
(Some(l), Some(v)) if !v.is_empty() => format!("{l}({v})"),
(Some(l), _) => l.clone(),
(None, _) => "us (libxkbcommon default)".to_string(),
}
}
}
/// The resolved layout plus where it came from, so the log line can name the file an operator
/// would have to edit.
#[derive(Clone, Debug)]
pub struct SystemLayout {
pub names: XkbNames,
/// Origin of `names.layout` specifically — the field that matters and the only one worth
/// naming in a one-line log.
pub source: String,
}
/// What `localectl set-x11-keymap` writes.
const X11_CONF_DIR: &str = "/etc/X11/xorg.conf.d";
/// systemd ≥ 249 mirrors the X11 keymap here as `XKBLAYOUT=`/`XKBVARIANT=`/…
const VCONSOLE_CONF: &str = "/etc/vconsole.conf";
/// Resolve the host's configured keyboard layout: `XKB_DEFAULT_*` env (explicit operator intent,
/// and what libxkbcommon would have used anyway) → `/etc/X11/xorg.conf.d/*keyboard*.conf` →
/// `/etc/vconsole.conf`. Per-field, so a partially-configured box keeps whatever each source knows.
pub fn system_layout() -> SystemLayout {
resolve_from(
from_env(),
Path::new(X11_CONF_DIR),
Path::new(VCONSOLE_CONF),
)
}
/// [`system_layout`] with its three inputs injected — the env block is a parameter so the tests
/// never depend on the `XKB_DEFAULT_*` of whatever machine runs them.
fn resolve_from(env: XkbNames, x11_dir: &Path, vconsole: &Path) -> SystemLayout {
let mut names = env;
let mut source = if names.layout.is_some() {
"XKB_DEFAULT_LAYOUT".to_string()
} else {
String::new()
};
for path in x11_keyboard_confs(x11_dir) {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let parsed = parse_x11_keyboard_conf(&text);
if source.is_empty() && parsed.layout.is_some() {
source = path.display().to_string();
}
names.fill_from(parsed);
}
if let Ok(text) = std::fs::read_to_string(vconsole) {
let parsed = parse_vconsole_conf(&text);
if source.is_empty() && parsed.layout.is_some() {
source = vconsole.display().to_string();
}
names.fill_from(parsed);
}
if source.is_empty() {
source = "unconfigured".to_string();
}
SystemLayout { names, source }
}
fn from_env() -> XkbNames {
let get = |k: &str| std::env::var(k).ok().filter(|v| !v.is_empty());
XkbNames {
rules: get("XKB_DEFAULT_RULES"),
model: get("XKB_DEFAULT_MODEL"),
layout: get("XKB_DEFAULT_LAYOUT"),
variant: get("XKB_DEFAULT_VARIANT"),
options: get("XKB_DEFAULT_OPTIONS"),
}
}
/// Every `*keyboard*.conf` in the Xorg snippet directory, in **reverse** lexical order. Xorg
/// merges these low-to-high with the later file winning; [`resolve_from`] fills fields first-hit-
/// wins, so handing it the highest-numbered snippet first reproduces that precedence.
fn x11_keyboard_confs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.extension().is_some_and(|e| e == "conf")
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("keyboard"))
})
.collect();
out.sort();
out.reverse();
out
}
/// Pull `Option "XkbLayout" "de"` style entries out of an Xorg `InputClass` snippet. Deliberately
/// section-blind: `localectl` writes exactly one `MatchIsKeyboard` class, and scanning every
/// `Option` line beats half-implementing the Xorg config grammar.
fn parse_x11_keyboard_conf(text: &str) -> XkbNames {
let mut names = XkbNames::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let mut fields = line.split('"');
// `Option ` | key | ` ` | value
let Some(head) = fields.next() else { continue };
if !head.trim_end().eq_ignore_ascii_case("Option") {
continue;
}
let (Some(key), Some(_), Some(value)) = (fields.next(), fields.next(), fields.next())
else {
continue;
};
let slot = match key.to_ascii_lowercase().as_str() {
"xkbrules" => &mut names.rules,
"xkbmodel" => &mut names.model,
"xkblayout" => &mut names.layout,
"xkbvariant" => &mut names.variant,
"xkboptions" => &mut names.options,
_ => continue,
};
*slot = Some(value.to_string());
}
names
}
/// Pull `XKBLAYOUT=de` / `XKBVARIANT="nodeadkeys"` out of `/etc/vconsole.conf`.
///
/// ⚠ `KEYMAP=` is deliberately ignored: it names a **console** keymap (`de-nodeadkeys`, `uk`,
/// `sg-latin1`), whose namespace only coincides with xkb's by accident — `uk` is xkb `gb`, and a
/// wrong guess here would mis-type every key rather than fall back to a visible default.
fn parse_vconsole_conf(text: &str) -> XkbNames {
let mut names = XkbNames::default();
for line in text.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim().trim_matches(['"', '\'']).to_string();
if value.is_empty() {
continue;
}
let slot = match key.trim().to_ascii_uppercase().as_str() {
"XKBRULES" => &mut names.rules,
"XKBMODEL" => &mut names.model,
"XKBLAYOUT" => &mut names.layout,
"XKBVARIANT" => &mut names.variant,
"XKBOPTIONS" => &mut names.options,
_ => continue,
};
*slot = Some(value);
}
names
}
#[cfg(test)]
mod tests {
use super::*;
/// Verbatim `localectl set-x11-keymap de pc105 nodeadkeys` output.
const LOCALECTL_DE: &str = r#"# Written by systemd-localed(8), read by systemd-localed and Xorg. It's
# probably wise not to edit this file manually. Use localectl(1) to
# update this file.
Section "InputClass"
Identifier "system-keyboard"
MatchIsKeyboard "on"
Option "XkbLayout" "de"
Option "XkbModel" "pc105"
Option "XkbVariant" "nodeadkeys"
EndSection
"#;
#[test]
fn parses_localectl_x11_snippet() {
let n = parse_x11_keyboard_conf(LOCALECTL_DE);
assert_eq!(n.layout.as_deref(), Some("de"));
assert_eq!(n.model.as_deref(), Some("pc105"));
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(n.rules, None);
assert_eq!(n.describe(), "de(nodeadkeys)");
}
#[test]
fn x11_snippet_ignores_comments_and_non_xkb_options() {
let n = parse_x11_keyboard_conf(
"# Option \"XkbLayout\" \"fr\"\n\
Identifier \"system-keyboard\"\n\
Option \"XkbLayout\" \"ch\"\n\
Option \"SomethingElse\" \"nope\"\n",
);
assert_eq!(n.layout.as_deref(), Some("ch"));
assert!(n.options.is_none());
}
#[test]
fn parses_vconsole_and_ignores_console_keymap() {
// The KEYMAP= line must NOT become an xkb layout: console and xkb namespaces differ.
let n = parse_vconsole_conf("KEYMAP=\"de-nodeadkeys\"\nFONT=\"eurlatgr\"\n");
assert!(n.is_empty(), "KEYMAP must not be read as an xkb layout");
let n = parse_vconsole_conf("XKBLAYOUT=de\nXKBVARIANT=\"nodeadkeys\"\nKEYMAP=de\n");
assert_eq!(n.layout.as_deref(), Some("de"));
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
}
#[test]
fn empty_names_describe_as_the_us_default() {
let n = XkbNames::default();
assert!(n.is_empty());
assert_eq!(n.describe(), "us (libxkbcommon default)");
assert_eq!(n.as_args(), ("", "", "", "", None));
assert!(n.env_pairs().is_empty());
}
#[test]
fn per_field_fallback_keeps_the_more_specific_source() {
// Env named only the layout; the X11 snippet still supplies model/variant.
let mut n = XkbNames {
layout: Some("fr".into()),
..Default::default()
};
n.fill_from(parse_x11_keyboard_conf(LOCALECTL_DE));
assert_eq!(n.layout.as_deref(), Some("fr"), "env layout must win");
assert_eq!(n.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(n.model.as_deref(), Some("pc105"));
}
#[test]
fn env_pairs_round_trip_only_the_resolved_fields() {
let n = XkbNames {
layout: Some("de".into()),
variant: Some("nodeadkeys".into()),
..Default::default()
};
assert_eq!(
n.env_pairs(),
vec![
("XKB_DEFAULT_LAYOUT", "de".to_string()),
("XKB_DEFAULT_VARIANT", "nodeadkeys".to_string()),
]
);
}
/// A throwaway `/etc` stand-in; the caller writes the two files it cares about.
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pf-layout-{}-{tag}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(dir.join("xorg.conf.d")).unwrap();
dir
}
#[test]
fn resolve_reads_the_x11_snippet_then_vconsole() {
let dir = scratch("x11-then-vconsole");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
let vconsole = dir.join("vconsole.conf");
std::fs::write(&vconsole, "XKBLAYOUT=fr\nXKBOPTIONS=compose:ralt\n").unwrap();
let got = resolve_from(XkbNames::default(), &x11, &vconsole);
// The X11 snippet outranks vconsole, so its layout stands; vconsole still fills the
// options nothing else supplied.
assert_eq!(got.names.layout.as_deref(), Some("de"));
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
assert_eq!(got.names.options.as_deref(), Some("compose:ralt"));
assert!(got.source.ends_with("00-keyboard.conf"), "{}", got.source);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_higher_numbered_xorg_snippet_wins() {
let dir = scratch("xorg-precedence");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
std::fs::write(
x11.join("90-custom-keyboard.conf"),
"Option \"XkbLayout\" \"no\"\n",
)
.unwrap();
let got = resolve_from(XkbNames::default(), &x11, &dir.join("absent"));
assert_eq!(got.names.layout.as_deref(), Some("no"));
// The `00-` file still supplies what `90-` left unsaid.
assert_eq!(got.names.variant.as_deref(), Some("nodeadkeys"));
assert!(
got.source.ends_with("90-custom-keyboard.conf"),
"{}",
got.source
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn env_outranks_every_file() {
let dir = scratch("env-wins");
let x11 = dir.join("xorg.conf.d");
std::fs::write(x11.join("00-keyboard.conf"), LOCALECTL_DE).unwrap();
let env = XkbNames {
layout: Some("us".into()),
..Default::default()
};
let got = resolve_from(env, &x11, &dir.join("absent"));
assert_eq!(got.names.layout.as_deref(), Some("us"));
assert_eq!(got.source, "XKB_DEFAULT_LAYOUT");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_on_a_bare_box_reports_unconfigured() {
let missing = Path::new("/nonexistent/pf-layout-test");
let got = resolve_from(XkbNames::default(), missing, missing);
assert_eq!(got.source, "unconfigured");
assert!(got.names.is_empty());
assert_eq!(got.names.describe(), "us (libxkbcommon default)");
}
}
+6
View File
@@ -33,6 +33,12 @@
//! share a name; do NOT conflate them.
#![forbid(unsafe_code)]
/// Which keyboard LAYOUT the box is configured for. Not a `PUNKTFUNK_*` knob — it is read from
/// what `localectl` recorded — but it is host configuration every input path needs (the injector
/// compiles its keymap from it; the gamescope backend hands it to the session it launches), and it
/// lives here so both can reach it without either crate depending on the other.
pub mod layout;
use std::sync::OnceLock;
/// Whether a `PUNKTFUNK_*` env var reads as ON, or `None` when it is unset — the host's
+29 -10
View File
@@ -275,18 +275,37 @@ impl WlrootsInjector {
pointer_mgr.create_virtual_pointer_with_output(Some(&seat), target.as_ref(), &qh, ());
let keyboard = keyboard_mgr.create_virtual_keyboard(&seat, &qh, ());
// The keymap the compositor resolves our raw evdev keycodes with. Empty names defer to
// the standard `XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS` env vars, then to
// libxkbcommon's built-ins (evdev/pc105/us) — so a non-US host sets e.g.
// `XKB_DEFAULT_LAYOUT=de` and the positional wire keys render as its layout (parity with
// the libei path, where the session compositor's own keymap applies). Previously this
// hardcoded "us", which forced US characters for the OEM/umlaut keys on every layout.
// The keymap the compositor resolves our raw evdev keycodes with. The wire keys are
// US-POSITIONAL, so this keymap is what decides the character each one finally types —
// it has to be the layout printed on the client's keyboard, or ISO keys render as their
// US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
//
// Resolved from the box's own configuration (`crate::layout`), NOT from empty names:
// empty defers to `XKB_DEFAULT_*`, which nothing on a Wayland session exports, so a
// `localectl set-x11-keymap de` host silently compiled evdev/pc105/**us**. (Before that
// it hardcoded "us" outright.) `XKB_DEFAULT_*` still wins when an operator sets it.
let resolved = pf_host_config::layout::system_layout();
let (rules, model, layout, variant, options) = resolved.names.as_args();
let ctx = xkb::Context::new(xkb::CONTEXT_NO_FLAGS);
let keymap =
xkb::Keymap::new_from_names(&ctx, "", "", "", "", None, xkb::KEYMAP_COMPILE_NO_FLAGS)
.context("compile xkb keymap (check XKB_DEFAULT_LAYOUT/VARIANT/RULES if set)")?;
let keymap = xkb::Keymap::new_from_names(
&ctx,
rules,
model,
layout,
variant,
options,
xkb::KEYMAP_COMPILE_NO_FLAGS,
)
.with_context(|| {
format!(
"compile xkb keymap {} (from {})",
resolved.names.describe(),
resolved.source
)
})?;
tracing::info!(
layout = %std::env::var("XKB_DEFAULT_LAYOUT").unwrap_or_else(|_| "us (default)".into()),
layout = %resolved.names.describe(),
source = %resolved.source,
"virtual keyboard keymap compiled"
);
let keymap_str = keymap.get_as_string(xkb::KEYMAP_FORMAT_TEXT_V1);
+3 -2
View File
@@ -6,8 +6,9 @@
//! Sway always advertises. We connect as an ordinary Wayland client (the host process
//! inherits Sway's `WAYLAND_DISPLAY`/`XDG_RUNTIME_DIR`), bind the two managers, and translate
//! events into virtual pointer/keyboard requests. Keyboard codes are Linux evdev; we upload an
//! xkb keymap (the host's layout via `XKB_DEFAULT_LAYOUT` et al., defaulting to evdev/US) and
//! track modifier state so the compositor resolves shifted keysyms correctly.
//! xkb keymap built from the box's configured layout (`pf_host_config::layout` — `XKB_DEFAULT_*`,
//! then what `localectl` recorded) and track modifier state so the compositor resolves shifted
//! keysyms correctly.
//!
//! Extracted into a subsystem crate (plan §W6): consumes `punktfunk_core::input` (the neutral
//! event vocabulary) + `pf-driver-proto` (the HID wire contract), never the orchestrator.
@@ -29,7 +29,7 @@ mod splash;
use discovery::{
check_gamescope_version, find_gamescope_eis_socket, find_gamescope_node, gamescope_bin,
gamescope_can_composite_external_overlay, gamescope_can_offer_refresh_rates,
gamescope_node_present, poll_managed_node, wait_for_node,
gamescope_honours_xkb_env, gamescope_node_present, poll_managed_node, wait_for_node,
};
pub(crate) use discovery::{
game_session_exited, gamescope_can_composite_cursor, gamescope_hdr_capable, is_available,
@@ -1228,8 +1228,10 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode, hdr: bool) -> Re
Environment=PF_H={h}\n\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{xkb}\
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
shim = shim_dir.display(),
xkb = xkb_unit_lines(),
w = mode.width,
h = mode.height,
hz = game_hz(mode.refresh_hz),
@@ -1315,8 +1317,10 @@ fn write_session_plus_dropin(
{binds}\
Environment=PF_HZ={hz}\n\
Environment=\"PF_HDR_ARGS={hdr_args}\"\n\
{xkb}\
{wsi}",
binds = bind.unit_lines(),
xkb = xkb_unit_lines(),
hz = game_hz(mode.refresh_hz),
hdr_args = hdr_args(hdr)
.into_iter()
@@ -3653,6 +3657,85 @@ fn point_injector_at_eis() {
"gamescope: no connectable gamescope EIS socket found — input won't reach the session"
),
}
sync_session_keyboard_layout();
}
/// Explicit-off kill switch for [`sync_session_keyboard_layout`].
const LAYOUT_SYNC_ENV: &str = "PUNKTFUNK_SESSION_LAYOUT";
/// `setxkbmap` talks to a local X server; anything slower than this is a server that is not
/// answering, and the connecting client is waiting on us.
const LAYOUT_SYNC_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
/// Align the session's Xwayland servers with the box's configured keyboard layout.
///
/// [`xkb_env`] only reaches a session punktfunk LAUNCHES. The common case on a Bazzite / SteamOS
/// box is the opposite one: the gaming session is already up from autologin, punktfunk ATTACHES to
/// it, and nothing decided at launch time applies — its Xwayland servers keep the `us` they were
/// born with. That matters because everything a gamescope session runs (Steam Big Picture, and
/// every Proton game) is an X11 client of those servers, so their keymap is what turns punktfunk's
/// US-positional key codes into characters. Without this, a German keyboard types `\` for `#`,
/// `'` for `ä` and `/` for `-` no matter what `localectl` says.
///
/// Best-effort and idempotent — re-applying the layout a server already has is a no-op, so this
/// runs on every adoption rather than reading the current one back. It does nothing at all when
/// the box configured no layout (there is nothing to align *to*, and inventing one would be worse
/// than the default), when no gamescope Xwayland is running, or when `PUNKTFUNK_SESSION_LAYOUT` is
/// explicitly off.
///
/// ⚠ Xwayland only. A Wayland-native client under gamescope takes the compositor's own keymap,
/// which is [`xkb_env`] plus the `+pfhdr8` patch — the two halves are not interchangeable.
fn sync_session_keyboard_layout() {
if pf_host_config::env_on(LAYOUT_SYNC_ENV) == Some(false) {
return;
}
let resolved = pf_host_config::layout::system_layout();
let Some(layout) = resolved.names.layout.as_deref() else {
return;
};
let targets = xwayland_cursor_targets();
if targets.is_empty() {
return;
}
let non_empty = |v: &Option<String>| v.as_deref().filter(|s| !s.is_empty()).map(str::to_owned);
for (dpy, xauth) in targets {
let mut cmd = Command::new("setxkbmap");
cmd.args(["-display", &dpy, "-layout", layout]);
if let Some(v) = non_empty(&resolved.names.variant) {
cmd.args(["-variant", &v]);
}
if let Some(m) = non_empty(&resolved.names.model) {
cmd.args(["-model", &m]);
}
// Only when configured: `-option ""` is setxkbmap's CLEAR, not its no-op.
if let Some(o) = non_empty(&resolved.names.options) {
cmd.args(["-option", &o]);
}
if let Some(xa) = &xauth {
cmd.env("XAUTHORITY", xa);
}
match crate::proc::status_within(&mut cmd, LAYOUT_SYNC_BUDGET) {
Ok(st) if st.success() => tracing::info!(
display = %dpy,
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope: aligned the session's keyboard layout with the box"
),
Ok(st) => tracing::warn!(
display = %dpy,
status = ?st.code(),
"gamescope: setxkbmap rejected the box's layout — the session keeps its own"
),
// Overwhelmingly "setxkbmap is not installed" (it ships in xorg-x11-xkb-utils /
// x11-xkb-utils). Not fatal: only a non-US keyboard notices, and it is exactly the
// case the +pfhdr8 gamescope handles without any of this.
Err(e) => tracing::warn!(
display = %dpy,
error = %e,
layout = %resolved.names.describe(),
"gamescope: could not set the session's keyboard layout (is setxkbmap installed?)"
),
}
}
}
/// Mirror the physical head this gamescope session is driving (`vdisplay::mirror`'s gamescope arm).
@@ -4553,6 +4636,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
for arg in wsi.setenv_args() {
cmd.arg(arg);
}
for arg in xkb_setenv_args() {
cmd.arg(arg);
}
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
// user manager env, which can carry a (possibly stale) desktop DISPLAY/WAYLAND_DISPLAY
// that would abort gamescope at startup.
@@ -4839,6 +4925,68 @@ fn cursor_args() -> Vec<String> {
args
}
/// The box's configured keyboard layout as `XKB_DEFAULT_*`, for a gamescope session we launch.
///
/// gamescope builds its xkb keymap from exactly these five variables and nothing else — it never
/// looks at `/etc/X11/xorg.conf.d/00-keyboard.conf`, which is where `localectl set-x11-keymap`
/// records the choice, because that file belongs to Xorg. Nothing in a systemd user session
/// exports them either, so a session launched without this runs libxkbcommon's built-in default:
/// evdev/pc105/**us**.
///
/// That matters because punktfunk's key wire is US-POSITIONAL — a client sends the *physical* key
/// and the session's keymap decides the character — so a US session renders a German keyboard's
/// ISO keys as their US neighbours (`#`→`\`, `ä`→`'`, `-`→`/`).
///
/// ⚠ Empty on a box that configured nothing, so an unconfigured session keeps behaving exactly as
/// it does today rather than being pinned to an invented layout.
///
/// ⚠ This is necessary but not, on its own, sufficient: gamescope only publishes the keymap to its
/// clients once a keyboard is actually bound to the seat, which on a HEADLESS session (no libinput
/// devices) needs the `punktfunk-gamescope` patch that gives the seat's stub keyboard the compiled
/// keymap. Against a stock gamescope these variables are read and then never reach Xwayland.
fn xkb_env() -> Vec<(&'static str, String)> {
let resolved = pf_host_config::layout::system_layout();
let pairs = resolved.names.env_pairs();
if pairs.is_empty() {
return pairs;
}
// Passed either way — it costs nothing against an older binary and starts working the moment
// the box updates — but say so, because "I set the layout and the keys are still US" is
// otherwise unexplainable from the outside.
if gamescope_honours_xkb_env() {
tracing::info!(
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope session: handing it the box's keyboard layout"
);
} else {
tracing::warn!(
layout = %resolved.names.describe(),
source = %resolved.source,
"gamescope session: this build ignores XKB_DEFAULT_* (needs punktfunk-gamescope \
+pfhdr8) the session will type US characters whatever the box is configured for"
);
}
pairs
}
/// [`xkb_env`] as `systemd-run --setenv=` arguments — mirrors [`WsiPlan::setenv_args`].
fn xkb_setenv_args() -> Vec<String> {
xkb_env()
.into_iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// [`xkb_env`] as unit-file lines for a drop-in. Trailing newline included, so whatever the body
/// puts after it still parses — same contract as [`WsiPlan::unit_lines`].
fn xkb_unit_lines() -> String {
xkb_env()
.into_iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
/// `--custom-refresh-rates <list>` when the resolved gamescope has it (patch level 3+): the rates a
/// HEADLESS session may offer its clients.
///
@@ -4950,6 +5098,8 @@ fn spawn(
cmd.args(app.split_whitespace())
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
// The box's keyboard layout — see [`xkb_env`]. Empty on an unconfigured box.
.envs(xkb_env())
// A HEADLESS gamescope must never attach to a parent compositor. A host (re)started after
// a desktop login inherits the user manager's DISPLAY/WAYLAND_DISPLAY — and a stale
// WAYLAND_DISPLAY (e.g. a leftover `wayland-kde` in the manager env from a past session)
@@ -507,6 +507,19 @@ pub(crate) fn gamescope_can_composite_cursor() -> bool {
gamescope_patch_level() >= 2 && !flags_lost()
}
/// Does the resolved gamescope actually PUBLISH the keymap it compiles from `XKB_DEFAULT_*`?
///
/// Below this level it reads the five variables, builds the keymap, and then hands its clients
/// nothing: the seat carries a keymap-less stub keyboard that `wlserver_keyboardfocus()` re-binds
/// on every focus change, so Xwayland and every Wayland client keep their own built-in `us`. A
/// headless session has no libinput devices to ever put the real keymap on the seat, so it never
/// recovers. The keys punktfunk injects are US-POSITIONAL and the session's keymap is what turns
/// them into characters — so on a stock gamescope a German keyboard types its US neighbours
/// (`#`→`\`, `ä`→`'`, `-`→`/`) no matter how the box is configured.
pub(crate) fn gamescope_honours_xkb_env() -> bool {
gamescope_patch_level() >= 8 && !flags_lost()
}
/// Does the resolved gamescope let us hand a headless session the list of refresh rates it may
/// offer (`--custom-refresh-rates`)?
///
+30
View File
@@ -304,6 +304,36 @@ were holding down is released on the host, so nothing sticks. The rest of the in
switch mouse mode, disconnect, fullscreen — are in
[Getting your input back](/docs/input#getting-your-input-back).
## My keyboard types the wrong characters (`#` comes out as `\`)
A German keyboard giving `\` for `#`, `'` for `ä` and `/` for `-`, or `z` and `y` swapped, is a
**host** layout mismatch — the client is fine.
Punktfunk sends the *physical key you pressed*, not the character, exactly as a keyboard plugged
into the host would. What that key finally types is decided by the layout the **host session** is
running, so the host has to be set to the same layout as the keyboard you're typing on. When it
isn't, every key whose position differs between the two layouts comes out as its neighbour.
On Linux, set the layout the normal way and reconnect:
```sh
sudo localectl set-x11-keymap de pc105 nodeadkeys # your layout, model, variant
```
Punktfunk reads that setting and hands it to the session on the next connect. Two things are worth
knowing:
- **Wayland desktops don't read it by themselves.** `localectl` writes a file only Xorg opens, so
before this release a correctly-configured box could still run a US session. If your compositor
is already set to the right layout in its own settings, nothing changes.
- **Game Mode needs a current `punktfunk-gamescope`.** Gamescope publishes no keyboard layout at
all to the apps it runs, so Steam and games saw US whatever the box was set to. Our build fixes
that from `+pfhdr8` on — check with `punktfunk-gamescope --version`, and
[update](/docs/updating) if it's older.
Nothing here changes which physical key does what in a game: `WASD` stays under the same fingers on
every layout.
## A controller is detected but games don't see it
- **Linux.** The host user needs to be in the `input` group. On Bazzite:
+2
View File
@@ -20,6 +20,7 @@ The patches here add the missing half, and nothing else. See
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
| `0009-pipewire-destroy-capture-textures-on-the-compositor-.patch` | Move capture-buffer destruction off the PipeWire thread: `remove_buffer`/stale-push queue the corpse (`bury_buffer`), steamcompmgr reaps on every vblank — including while the stream is paused, which is exactly the linger window. Without it, dropping the last `CVulkanTexture` ref on the PW thread races `vulkan_screenshot` on the same device and SIGSEGVs (NVIDIA `insertBarrier`), so a lingered display is dead and reconnect loses the session. Reported + written by luxus (punktfunk-overlay#9) | **Yes** — the race is upstream's `paint_pipewire` vs `destroy_buffer`; our patches only make the paint path heavier |
| `0010-wlserver-give-the-seat-s-stub-keyboard-the-compiled-.patch` | Set the compiled keymap on `wlserver.wlr.virtual_keyboard_device` too. gamescope builds a keymap from `XKB_DEFAULT_*` but only puts it on `keyboard_group`, while the SEAT carries the keymap-less stub that `wlserver_keyboardfocus()` re-binds on every focus change — so clients get no keymap and fall back to their own `us`, and a headless session (no libinput devices) never recovers | **Yes** — the stub's own comment says it exists "only to set the keymap"; it just never did |
### Why the headless patch matters
@@ -103,6 +104,7 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
| `+pfhdr7` | …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability) |
| `+pfhdr8` | …and the seat's keyboard carries the `XKB_DEFAULT_*` keymap, so the session follows the box's configured layout |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
@@ -0,0 +1,67 @@
From d958d5451d0df3678cdccbb5d7306c15b504c877 Mon Sep 17 00:00:00 2001
From: enricobuehler <enrico.buehler@unom.io>
Date: Sat, 15 Aug 2026 20:15:12 +0200
Subject: [PATCH] wlserver: give the seat's stub keyboard the compiled keymap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
gamescope reads XKB_DEFAULT_RULES/MODEL/LAYOUT/VARIANT/OPTIONS and compiles a keymap from them,
but that keymap only ever lands on wlserver.keyboard_group. The seat carries a DIFFERENT object:
wlserver_keyboardfocus() calls wlr_seat_set_keyboard(seat, wlserver.wlr.virtual_keyboard_device)
on every focus change, and that stub — created a few lines above with wlr_keyboard_init(kbd,
nullptr, "virtual") and the comment "only used to set the keymap" — never has one set.
With a NULL keymap wlroots advertises none, so every client falls back to its own compiled-in
default and gamescope's session is us/pc105 whatever XKB_DEFAULT_LAYOUT says. The group does
reach the seat, but only from wlserver_handle_key/_modifiers, i.e. from a real libinput key
event — and the next focus change swaps the stub back in anyway. A HEADLESS session (--backend
headless, no libinput devices at all) therefore never gets a keymap onto the seat at all.
Observed on a Bazzite gaming-mode host configured de/nodeadkeys via localectl: both gamescope
Xwayland servers report evdev/pc105/us, and every ISO key types its US neighbour (# -> backslash,
adiaeresis -> apostrophe, minus -> slash). Injected input (libei/EIS) hits exactly the same path,
which is how a remote client with a German keyboard ends up typing US characters.
Set the keymap on the stub too.
---
src/meson.build | 3 ++-
src/wlserver.cpp | 8 ++++++++
2 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/src/meson.build b/src/meson.build
index fe854af..91bba6b 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -187,7 +187,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
# +pfhdr7 — …and PipeWire teardown cannot SIGSEGV a lingering compositor (no new capability)
-version_tag = vcs_tag + '+pfhdr7' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr8 — …and the seat's keyboard carries the XKB_DEFAULT_* keymap (honours the box layout)
+version_tag = vcs_tag + '+pfhdr8' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/wlserver.cpp b/src/wlserver.cpp
index 92f2807..3354da7 100644
--- a/src/wlserver.cpp
+++ b/src/wlserver.cpp
@@ -2042,6 +2042,14 @@ bool wlserver_init( void ) {
struct wlr_keyboard *keyboard = &wlserver.keyboard_group->keyboard;
wlr_keyboard_set_repeat_info(keyboard, 25, 600);
wlr_keyboard_set_keymap(keyboard, keymap);
+ // The seat carries the STUB keyboard, not the group: wlserver_keyboardfocus() binds
+ // wlserver.wlr.virtual_keyboard_device on every focus change, and the group only reaches the
+ // seat from wlserver_handle_key/_modifiers — i.e. from a real libinput key event. Left with a
+ // NULL keymap the stub makes wlroots advertise no keymap at all, so every client (Xwayland
+ // included) keeps its own built-in "us" and XKB_DEFAULT_* is silently ignored. A headless
+ // session has no libinput devices, so it never recovers: the very next focus change puts the
+ // keymap-less stub back. Give the stub the keymap its own comment above says it exists for.
+ wlr_keyboard_set_keymap(wlserver.wlr.virtual_keyboard_device, keymap);
wlserver.keyboard_group_modifiers.notify = wlserver_handle_modifiers;
wl_signal_add(&keyboard->events.modifiers, &wlserver.keyboard_group_modifiers);
wlserver.keyboard_group_key.notify = wlserver_handle_key;
--
2.50.1 (Apple Git-155)