Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build:
runs-on: [self-hosted, Linux, X64, splitux]
# 2026-07-03: moved off the shared bare-metal `splitux` fleet onto a
# dedicated ephemeral, per-repo container runner (Phase 2 of the
# org-wide containerization pilot - see ~/ssh-workspace/ci-runners/README.md
# on the lab). gamescope-splitux and gbe_fork-splitux's Linux jobs keep
# using the shared `splitux` label, completely unaffected.
runs-on: [self-hosted, Linux, X64, splitux-ci]
needs: [cleanup-nightly]
if: always() && (needs.cleanup-nightly.result == 'success' || needs.cleanup-nightly.result == 'skipped')

Expand Down Expand Up @@ -339,7 +344,12 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

build-flatpak:
runs-on: [self-hosted, Linux, X64, splitux]
# Same dedicated ephemeral runner as `build` above. This job's
# flatpak-builder step runs a nested bubblewrap sandbox internally - the
# ephemeral container it runs in needs --cap-add SYS_ADMIN
# --security-opt unmask=ALL at launch (set in the poller's systemd unit
# on the lab) or bwrap's own proc mount is rejected.
runs-on: [self-hosted, Linux, X64, splitux-ci]
needs: [build]
if: always() && needs.build.result == 'success'

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "splitux"
version = "0.19.0"
version = "0.20.0"
edition = "2024"

[dependencies]
Expand Down
9 changes: 7 additions & 2 deletions src/backend/goldberg/operations/steamless.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ fn has_steamstub_elf(data: &[u8]) -> bool {
let Some(name_off) = rd_u32(data, sh).map(|v| strtab_off + v as usize) else {
continue;
};
let end = data[name_off..].iter().position(|&b| b == 0).unwrap_or(0);
if &data[name_off..name_off + end] == b".bind" {
// Bounds-check: section-header fields are packer-controlled, so an
// out-of-range name offset must decide "not DRM-wrapped", not panic.
let Some(rest) = data.get(name_off..) else {
continue;
};
let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len());
if &rest[..end] == b".bind" {
return true;
}
}
Expand Down
22 changes: 18 additions & 4 deletions src/backend/keen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use std::process::{Child, Command, Stdio};

use crate::handler::Handler;
use crate::instance::Instance;
use crate::paths::{BIN_KEEN_EMU, PATH_PARTY};
use crate::paths::BIN_KEEN_EMU;

/// Keen emulator settings from handler YAML (dot-notation: keen.*)
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
Expand Down Expand Up @@ -57,8 +57,15 @@ impl Keen {
}

/// Path the emu writes its data file to (shared across all instances of the run).
///
/// Per-launch namespaced (`tmp/<launch_ns>/keen/`) so a SECOND concurrent splitux
/// process can't overwrite a running session's freshly-generated keypair file
/// mid-game. NB: the emu's loopback PORT (`settings.addr`, default 27503) is
/// still fixed, so two concurrent Keen-gated sessions on one host still serialize
/// on that port — a per-launch port would need the keen-emu binary to advertise
/// its bound port back into this data file.
fn data_file_path() -> PathBuf {
PATH_PARTY.join("keen").join("keenonline-emu.json")
crate::paths::launch_tmp_dir().join("keen").join("keenonline-emu.json")
}

impl Backend for Keen {
Expand Down Expand Up @@ -89,6 +96,13 @@ impl Backend for Keen {
/// auth at the emu. For Proton/wine titles the path is given as a Windows
/// path on the `Z:` drive (which wine maps to `/`).
fn extra_launch_args(&self, _handler: &Handler, is_windows: bool) -> Vec<String> {
// Lockstep with start_services' presence guard: if the sideloaded emu
// isn't installed we never start it, so don't point the game at a data
// file it never wrote (that just hangs/fails the Keen handshake). Inject
// nothing and let the game run without the Keen auth gate.
if !BIN_KEEN_EMU.exists() {
return Vec::new();
}
let p = data_file_path();
let path_arg = if is_windows {
format!("Z:{}", p.to_string_lossy().replace('/', "\\"))
Expand Down Expand Up @@ -125,8 +139,8 @@ impl Backend for Keen {
let child = Command::new(&bin)
.env("KEEN_ADDR", &self.settings.addr)
.env("KEEN_DATA_FILE", &data_file)
.stdout(Stdio::null())
.stderr(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?;

// Give the emu a moment to bind and write the data file before any game
Expand Down
8 changes: 4 additions & 4 deletions src/backend/standalone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use crate::bepinex::install_plugin_dlls;
use crate::handler::Handler;
use crate::instance::Instance;
use crate::mods::{self, filter_plugin_files, PluginSource};
use crate::paths::PATH_PARTY;

/// Standalone backend settings
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
Expand Down Expand Up @@ -88,9 +87,10 @@ impl Backend for Standalone {
let mut overlays = Vec::new();

for (i, &global_index) in global_indices.iter().enumerate() {
// GLOBAL index in the dir name so two concurrent games don't collide.
let overlay_dir = PATH_PARTY
.join("tmp")
// Per-launch namespaced scratch (tmp/<launch_ns>/standalone-<idx>) so
// two CONCURRENT splitux processes don't collide on tmp/standalone-0
// and wipe each other's live overlay — matching prepare_overlay_dir.
let overlay_dir = crate::paths::launch_tmp_dir()
.join(format!("standalone-{}", global_index));

// Clean previous overlay
Expand Down
93 changes: 61 additions & 32 deletions src/launch/pipelines/build_cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,19 @@ pub fn launch_cmds(

let mut cmds: Vec<(Command, usize)> = Vec::new();

// How many instances share each monitor index — used below to decide
// gamescope-bypass PER INSTANCE rather than for the launch as a whole, so
// a local instance alone on its own monitor can bypass even in a mixed
// launch that also has a together instance (on a different monitor).
let mon_instance_counts: Vec<usize> = {
let max_monitor = instances.iter().map(|inst| inst.monitor).max().unwrap_or(0);
let mut counts = vec![0usize; max_monitor + 1];
for inst in instances.iter() {
counts[inst.monitor] += 1;
}
counts
};

for (i, instance) in instances.iter().enumerate() {
// This instance's unit handler. Single-game: always handlers[0].
let h = &handlers[instance.game];
Expand All @@ -102,6 +115,17 @@ pub fn launch_cmds(
let game_inst_count = game_inst_counts[i];
let is_first_in_game = game_inst_num == 0;

// Wine-prefix disambiguator: how many EARLIER instances in this launch
// share this one's (game, profile)? 0 for the first (the common
// one-instance-per-profile case keeps its bare, reusable per-profile
// prefix); >0 gives same-profile siblings a distinct `-p<n>` prefix so
// Proton's STEAM_COMPAT_DATA_PATH file-lock can't block the 2nd from
// booting (two seats on one profile would otherwise share one prefix).
let prefix_dup_idx = instances[..i]
.iter()
.filter(|prev| prev.game == instance.game && prev.profname == instance.profname)
.count();

let gamedir = if h.is_saved_handler() && !cfg.disable_mount_gamedirs {
crate::paths::launch_tmp_dir().join(format!("game-{}", i))
} else {
Expand Down Expand Up @@ -138,16 +162,22 @@ pub fn launch_cmds(
let seats: &[crate::together::TogetherSeatDevices] =
together_devices.get(i).map(Vec::as_slice).unwrap_or(&[]);

// Gamescope-bypass (cfg.disable_gamescope): run a single LOCAL seat
// directly under the host compositor, with NO nested gamescope. ONLY for
// a lone, non-together, bwrap'd instance — split-screen needs gamescope's
// per-instance geometry and together needs its PipeWire capture, so those
// always keep the nested compositor. Removes the double-compositor
// scan-line artifact on high-refresh panels (see SplituxConfig docs). The
// game inherits the session's X display (Xwayland-satellite), so its wine
// display driver is unchanged — only the redundant compositor is dropped.
// Gamescope-bypass (cfg.disable_gamescope): run a LOCAL seat directly
// under the host compositor, with NO nested gamescope. Decided PER
// INSTANCE (by monitor, not by launch-wide instance count): safe only
// when this instance doesn't share its monitor with another instance
// (same-monitor split-screen needs gamescope's per-instance geometry)
// and this instance itself has no together seats (together needs
// PipeWire capture, which requires the nested compositor). A mixed
// launch — e.g. one local instance fullscreen on its own monitor plus
// a together instance on another — lets the local instance bypass
// while the together instance keeps nested gamescope. Removes the
// double-compositor scan-line artifact on high-refresh panels (see
// SplituxConfig docs). The game inherits the session's X display
// (Xwayland-satellite), so its wine display driver is unchanged —
// only the redundant compositor is dropped.
let bypass_gamescope = h.effective_disable_gamescope(cfg)
&& instances.len() == 1
&& mon_instance_counts[instance.monitor] == 1
&& seats.is_empty()
&& !h.disable_bwrap;
if bypass_gamescope {
Expand Down Expand Up @@ -182,8 +212,15 @@ pub fn launch_cmds(
cmd.env(k, v);
}

// Proton debug logging
// Proton debug logging. PROTON_LOG_DIR is set per-instance because
// Proton's default log path ($HOME/steam-<appid>.log) is keyed only by
// SteamAppId — with 2+ concurrent instances of the SAME game, they all
// open the same path in truncate mode and clobber each other's log,
// making a real per-instance failure look like a near-empty log.
cmd.env("PROTON_LOG", "1");
let path_protonlog = crate::paths::launch_tmp_dir().join(format!("proton-log-{}", i));
std::fs::create_dir_all(&path_protonlog)?;
cmd.env("PROTON_LOG_DIR", &path_protonlog);
cmd.env("WINEDEBUG", "trace+dinput,trace+xinput");
cmd.env("PROTON_USE_XALIA", "0");

Expand All @@ -201,7 +238,7 @@ pub fn launch_cmds(

// Proton environment (for Windows games)
if win {
proton::setup_env(&mut cmd, h, cfg, &instance.profname, instance.game);
proton::setup_env(&mut cmd, h, cfg, &instance.profname, instance.game, prefix_dup_idx);

// Gamescope-bypass: with no nested gamescope there is no embedded
// Xwayland, and the host's rootless Xwayland (xwayland-satellite)
Expand Down Expand Up @@ -448,7 +485,7 @@ pub fn launch_cmds(
// which falls through to steam://run and exits. Writing goldberg's
// here makes the warm path correct; the shadow (b) covers the
// cold/copy-runs path (Proton then copies goldberg, not real Steam).
let pfx_steam = proton::get_prefix_path(cfg, &instance.profname, instance.game)
let pfx_steam = proton::get_prefix_path(cfg, &instance.profname, instance.game, prefix_dup_idx)
.join("drive_c/Program Files (x86)/Steam");
match std::fs::create_dir_all(&pfx_steam) {
Ok(()) => {
Expand Down Expand Up @@ -529,25 +566,17 @@ pub fn launch_cmds(

// Get gamepad paths for this instance
let mut gamepad_paths = bwrap::get_assigned_gamepad_paths(input_devices, &instance.devices);
// Remote seats set to Gamepad input contribute their virtual pad, so
// the game's SDL reads the friend's controller. Kb+Mouse seats add no
// pad (no phantom controller for a pad-based game). A local-split
// instance carries several gamepad seats → several pads on one game.
//
// EXCEPTION — EOS games: a session JOIN only completes once the game
// has a device-backed local player. UE CommonUser binds a UserIdx on
// a "controller connection changed" event; a Kb+Mouse seat is injected
// by gamescope and exposes NO input device, so the joiner never binds a
// local player and the EOS join silently aborts to the menu (E007) —
// the game receives JoinSession=EOS_Success but then makes no further
// EOS calls and never travels. The seat-streamer always creates a
// virtual pad (wait_for_seat_devices requires pad+kbd+mouse), so for
// EOS games we wire it in for Kb+Mouse seats too: it gives the joiner
// the controller-connection it needs to bind a local player while
// keyboard/mouse still drive gameplay via gamescope injection.
let wire_seat_pads = instance.together_input
== crate::instance::TogetherInput::Gamepad
|| h.has_eos();
// Every together seat wires its virtual pad — the remote player drives
// whichever device they actually have (browser gamepad → pad, keys/touch
// → gamescope-injected kbd/mouse) without the seat type pre-deciding it.
// This also keeps EOS games working: a session JOIN only completes once
// the game binds a UserIdx on a "controller connection changed" event,
// so a seat with no pad device silently aborts the join to the menu
// (E007). The seat-streamer always creates the pad; wiring it costs a
// dormant controller at worst. (Formerly gated on TogetherInput::Gamepad
// — a phantom-controller precaution that in practice just made kbm seats
// padless; per 2026-07-02 directive all input goes to all together seats.)
let wire_seat_pads = !seats.is_empty();
if wire_seat_pads {
for seat in seats {
if let Some(pad) = &seat.pad {
Expand Down Expand Up @@ -604,7 +633,7 @@ pub fn launch_cmds(

// 5. Profile bindings
if win {
let path_pfx_user = proton::get_prefix_user_path(cfg, &instance.profname, instance.game);
let path_pfx_user = proton::get_prefix_user_path(cfg, &instance.profname, instance.game, prefix_dup_idx);
cmd.arg("--bind")
.args([&path_prof.join("windata"), &path_pfx_user]);
} else {
Expand Down
44 changes: 34 additions & 10 deletions src/launch/pipelines/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ pub fn launch_game(
monitors: &[Monitor],
cfg: &SplituxConfig,
ready: &std::sync::atomic::AtomicBool,
displays_assigned: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// All handler reads are now per-unit: session-wide steps that genuinely need
// a representative handler index `handlers[…]` explicitly; the spawn loop and
Expand Down Expand Up @@ -241,21 +240,46 @@ pub fn launch_game(
}
println!("[splitux] Layout: instance_to_region = {:?}", instance_to_region);

// Gamescope-bypass: mirror the per-instance decision in build_cmds so the WM
// knows the game window is a plain host surface (not a gamescope window).
// Engages only for a lone, non-together, bwrap'd local seat with
// `disable_gamescope` set; multi-seat / together always keep gamescope.
let no_gamescope = instances.len() == 1
&& !instances.iter().any(|inst| inst.together)
&& !handlers[instances[0].game].disable_bwrap
&& handlers[instances[0].game].effective_disable_gamescope(cfg);
// Gamescope-bypass: mirror build_cmds' per-instance decision EXACTLY so the WM
// agrees with the commands that were actually built. build_cmds gates on the
// instance's resolved seat list being empty (`seats.is_empty()`), NOT on
// `inst.together` — a together instance whose seat-streamer failed to spawn
// has an empty seat list, so build_cmds bypasses gamescope; if we keyed off
// `inst.together` here instead, the WM would wait for a gamescope window that
// was never created and the LaunchGuard would kill the running game.
//
// Computed per instance (same monitor-sharing rule as build_cmds' bypass_gamescope):
// a mixed launch — e.g. one local instance alone on its own monitor plus a
// together instance on another — bypasses gamescope for the local instance
// only. `no_gamescope` (used by the WM to relax window matching and to pick
// the best-effort — vs hard-fail — wait/position path) is true whenever ANY
// instance bypasses: the WM's per-window ownership resolution (see
// `wm::niri::resolve_window_instance`) then sorts out which window belongs to
// which instance, so relaxing matching for the whole launch is safe. This does
// mean a mixed launch never takes the strict hard-fail path even for its
// gamescope-backed instances — an intentional trade toward "never kill a
// running game over a WM positioning miss" (see the soft-path comment below).
let mon_instance_counts: Vec<usize> = {
let max_monitor = instances.iter().map(|inst| inst.monitor).max().unwrap_or(0);
let mut counts = vec![0usize; max_monitor + 1];
for inst in instances.iter() {
counts[inst.monitor] += 1;
}
counts
};
let no_gamescope = instances.iter().enumerate().any(|(i, inst)| {
let h = &handlers[inst.game];
h.effective_disable_gamescope(cfg)
&& mon_instance_counts[inst.monitor] == 1
&& together_devices.get(i).map(|s| s.is_empty()).unwrap_or(true)
&& !h.disable_bwrap
});

let ctx = LayoutContext {
instances: instances.to_vec(),
monitors: monitors.to_vec(),
preset,
instance_to_region,
displays_assigned,
no_gamescope,
};

Expand Down
4 changes: 1 addition & 3 deletions src/launch/pipelines/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ pub fn run_launch(
master_profile,
ready,
notify,
use_multimonitor,
);
}

Expand All @@ -95,7 +94,6 @@ pub fn run_session(
master_profile: Option<&str>,
ready: &AtomicBool,
notify: &dyn Fn(&str, &str),
displays_assigned: bool,
) {
// Single-game shim: until profile/save setup is grouped per game (step 4),
// these session-level steps operate on the first (only) game's handler. A
Expand Down Expand Up @@ -127,7 +125,7 @@ pub fn run_session(

// Note: fuse_overlayfs_mount_gamedirs runs inside launch_cmds with proper
// Goldberg overlay support.
if let Err(err) = launch_game(handlers, dev_infos, instances, monitors, cfg, ready, displays_assigned) {
if let Err(err) = launch_game(handlers, dev_infos, instances, monitors, cfg, ready) {
println!("[splitux] Error launching instances: {}", err);
notify("Launch Error", &format!("{err}"));
}
Expand Down
Loading
Loading