Games ran black in Nobara's Game Mode — the session script re-exports the WSI variable #144 cleared #165

Merged
enricobuehler merged 1 commits from worktree-nobara-wsi-layer-clobber into main 2026-08-11 18:33:13 +00:00
2 changed files with 114 additions and 11 deletions
+21
View File
@@ -42,6 +42,27 @@ availability probe. The `comm` fast path is still one read for every ordinary di
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
### Game Mode on Nobara — the WSI opt-out never reached the games
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
input over a black picture, with no error on either side.
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
near the top of the script — before it launches anything — so the opt-out survived exactly as long
as it took the script to start, and every process the session spawned got the layer back. Nothing
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
session script never mentions that second variable, so it is the one that survives. Both spellings
go out, on the transient unit and on the box's own session drop-in.
## v0.27.0
87 commits since v0.26.0.
@@ -1032,7 +1032,7 @@ fn write_session_plus_dropin(
wsi = if wsi_ok {
String::new()
} else {
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
wsi_off_unit_lines()
},
);
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
@@ -3521,6 +3521,52 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
Some(bind)
}
/// The environment that turns the distro's `VkLayer_FROG_gamescope_wsi` off for a whole session
/// tree — applied wherever we start one: [`launch_session`]'s transient unit and the box-session
/// drop-in ([`write_session_plus_dropin`]).
///
/// ⭐⭐⭐ **`DISABLE_GAMESCOPE_WSI` is the one that actually works, and it is not the obvious one.**
/// `gamescope-session-plus` does an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of
/// the script, before it launches anything — so a `--setenv=ENABLE_GAMESCOPE_WSI=0` on the unit is
/// CLOBBERED for the script and every child it spawns: gamescope, Steam, and every game Steam
/// launches. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order
/// (`loader.c`, `loader_implicit_layer_is_enabled`): `enable_environment` switches the layer on
/// only when the variable equals exactly `"1"`, and then `disable_environment` is consulted last —
/// *"has priority over everything else"* — where the mere PRESENCE of the variable, at any value,
/// forces the layer off. Nothing in the session script mentions `DISABLE_GAMESCOPE_WSI`, so it is
/// the only one of the two that survives the script.
///
/// ⚠️⚠️ What getting this wrong looks like in the field (Nobara 44, 2026-08-11): the layer stayed
/// on for games while this host's own log said it had been disabled. Neither Steam Big Picture nor
/// mangoapp is a Vulkan client, so both paint regardless and the session looks perfectly healthy —
/// right up until a game starts. Then the game runs with sound and input while its swapchain is
/// dead: a black screen, and not one line of error anywhere.
///
/// `ENABLE_GAMESCOPE_WSI=0` is kept alongside for a layer built without a `disable_environment`
/// (the loader warns about such a layer but honours its `enable_environment`), and because it is
/// what an operator reading the unit will look for.
const WSI_OFF_ENV: [(&str, &str); 2] = [
("DISABLE_GAMESCOPE_WSI", "1"),
("ENABLE_GAMESCOPE_WSI", "0"),
];
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
fn wsi_off_setenv_args() -> Vec<String> {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("--setenv={name}={value}"))
.collect()
}
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
fn wsi_off_unit_lines() -> String {
WSI_OFF_ENV
.iter()
.map(|(name, value)| format!("Environment={name}={value}\n"))
.collect()
}
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
///
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
@@ -3533,8 +3579,10 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
/// bump — which is why the check is "do the version triples differ", not a floor.
///
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
/// metadata) — far cheaper than a client that cannot start.
///
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
fn wsi_layer_matches_our_gamescope() -> bool {
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
@@ -3589,14 +3637,17 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
// an armed bind is the one thing here that can stop the session coming up at all.
let mut bind = arm_session_bind(&wrapper);
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
let wsi_ok = wsi_layer_matches_our_gamescope();
if !wsi_ok {
tracing::warn!(
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
than the one we run disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
which shows up as a black screen with no other symptom."
than the one we run disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
Steam's own UI is not one, so what you see is a game that runs with sound and input \
on a black screen, with no other symptom."
);
}
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
@@ -3606,7 +3657,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
cmd.arg(arg);
}
if !wsi_ok {
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
for arg in wsi_off_setenv_args() {
cmd.arg(arg);
}
}
let status = cmd
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
@@ -4043,9 +4096,9 @@ mod tests {
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
shape_dedicated_command, switch_ends_mask_window, unmask_unit, wsi_off_setenv_args,
wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
@@ -4706,4 +4759,33 @@ mod tests {
None
);
}
/// `gamescope-session-plus` runs `export ENABLE_GAMESCOPE_WSI=1` before it launches anything,
/// so that variable alone cannot turn the layer off for Steam or for the games Steam launches
/// — only `DISABLE_GAMESCOPE_WSI`, which the script never mentions and which the Vulkan loader
/// treats as an unconditional force-off, survives. Dropping it would restore the field bug
/// (a game with sound and input on a black screen) while the host's log still claimed the
/// layer was disabled, which is what made it so expensive to find. See [`WSI_OFF_ENV`].
#[test]
fn the_wsi_opt_out_carries_the_variable_the_session_script_cannot_clobber() {
assert!(
WSI_OFF_ENV.contains(&("DISABLE_GAMESCOPE_WSI", "1")),
"the clobber-proof variable is the whole point of the opt-out"
);
// Both spellings reach both launch paths, and neither may lose the other.
let args = wsi_off_setenv_args();
let lines = wsi_off_unit_lines();
for (name, value) in WSI_OFF_ENV {
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
assert!(
lines.contains(&format!("Environment={name}={value}\n")),
"{name}"
);
}
// Trailing newline: the drop-in body appends nothing after this block today, but the bind
// lines above it rely on the same contract and the order has changed before.
assert!(lines.ends_with('\n'));
}
}