From d73a6787a9ec4b4ae9ddebd7bee4aeb9c18987dd Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Tue, 30 Jun 2026 15:04:02 -0400 Subject: [PATCH 1/7] fix: address all 10 code-review findings (crashes, data loss, layout, isolation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the high-effort workflow review of the branch — all findings resolved: Crashes / data loss: - util.rs clear_tmp(): reap only dead-pid orphan scratch instead of rm -rf'ing the whole tmp/ tree (could delete through a concurrent live session's overlay into the player's save upperdir). - steamless.rs has_steamstub_elf(): bounds-check the packer-controlled ELF section name offset so a bad offset returns 'not DRM' instead of panicking the launch. Launch correctness (this session's gamescope-bypass + Keen work): - execute.rs no_gamescope: gate on the resolved seat-list emptiness (matching build_cmds) not inst.together, so a failed together seat can't make the WM wait for a gamescope window that was never built and kill the game. - keen.rs extra_launch_args(): skip --keenonline-server-data-file when the sideloaded emu binary is absent (lockstep with start_services' guard). Concurrency isolation: - standalone.rs: overlay under the per-launch namespaced tmp dir. - keen.rs data_file_path(): per-launch namespaced (loopback port still emu-side). - proton.rs get_prefix_path(): disambiguate two same-profile instances in one launch with a -p suffix (first keeps its bare per-profile prefix) so Proton's prefix file-lock can't block the 2nd from booting. Window-manager layout: - niri.rs fullscreen: always honor the assigned monitor; never auto-spread onto another output (could land on a virtual Sunshine display → wedge). Removed the now-dead displays_assigned plumbing. - niri.rs tile_windows_on_monitor: index the original window snapshot instead of re-fetching + stale-indexing. - layout.rs Grid: honor window_count instead of a fixed 2x2. --- src/backend/goldberg/operations/steamless.rs | 9 ++- src/backend/keen.rs | 18 +++++- src/backend/standalone.rs | 8 +-- src/launch/pipelines/build_cmds.rs | 17 ++++- src/launch/pipelines/execute.rs | 16 +++-- src/launch/pipelines/session.rs | 4 +- src/proton.rs | 20 ++++-- src/util.rs | 18 +++--- src/wm.rs | 8 --- src/wm/niri.rs | 67 ++++++-------------- src/wm/pure/layout.rs | 34 ++++++---- 11 files changed, 116 insertions(+), 103 deletions(-) diff --git a/src/backend/goldberg/operations/steamless.rs b/src/backend/goldberg/operations/steamless.rs index 68aab19..18a39f9 100644 --- a/src/backend/goldberg/operations/steamless.rs +++ b/src/backend/goldberg/operations/steamless.rs @@ -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; } } diff --git a/src/backend/keen.rs b/src/backend/keen.rs index 57d21df..b7e2438 100644 --- a/src/backend/keen.rs +++ b/src/backend/keen.rs @@ -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)] @@ -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//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 { @@ -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 { + // 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('/', "\\")) diff --git a/src/backend/standalone.rs b/src/backend/standalone.rs index b6492ec..788fcc7 100644 --- a/src/backend/standalone.rs +++ b/src/backend/standalone.rs @@ -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)] @@ -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//standalone-) 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 diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index 8af3ca6..55d44a4 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -102,6 +102,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` 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 { @@ -201,7 +212,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) @@ -448,7 +459,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(()) => { @@ -604,7 +615,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 { diff --git a/src/launch/pipelines/execute.rs b/src/launch/pipelines/execute.rs index cd1f8de..1edf3f8 100644 --- a/src/launch/pipelines/execute.rs +++ b/src/launch/pipelines/execute.rs @@ -94,7 +94,6 @@ pub fn launch_game( monitors: &[Monitor], cfg: &SplituxConfig, ready: &std::sync::atomic::AtomicBool, - displays_assigned: bool, ) -> Result<(), Box> { // All handler reads are now per-unit: session-wide steps that genuinely need // a representative handler index `handlers[…]` explicitly; the spawn loop and @@ -241,12 +240,16 @@ 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. + // Gamescope-bypass: mirror build_cmds' per-instance decision EXACTLY so the WM + // agrees with the command that was 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. Use the + // same seat-list emptiness (instance 0, since this only engages for len==1). let no_gamescope = instances.len() == 1 - && !instances.iter().any(|inst| inst.together) + && together_devices.first().map(|s| s.is_empty()).unwrap_or(true) && !handlers[instances[0].game].disable_bwrap && handlers[instances[0].game].effective_disable_gamescope(cfg); @@ -255,7 +258,6 @@ pub fn launch_game( monitors: monitors.to_vec(), preset, instance_to_region, - displays_assigned, no_gamescope, }; diff --git a/src/launch/pipelines/session.rs b/src/launch/pipelines/session.rs index baa742b..2f69767 100644 --- a/src/launch/pipelines/session.rs +++ b/src/launch/pipelines/session.rs @@ -77,7 +77,6 @@ pub fn run_launch( master_profile, ready, notify, - use_multimonitor, ); } @@ -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 @@ -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}")); } diff --git a/src/proton.rs b/src/proton.rs index f9388ec..f4ac2cd 100644 --- a/src/proton.rs +++ b/src/proton.rs @@ -19,7 +19,13 @@ use crate::util::resolve_proton_path; /// (== the prefix), so a shared prefix means only one same-game instance boots. /// Keyed by profile (vs launch namespace) so each user's prefix is STABLE/reusable /// across launches — no Wine-prefix re-init cost per run. -pub fn get_prefix_path(cfg: &SplituxConfig, profname: &str, game: usize) -> PathBuf { +/// +/// `dup_idx` disambiguates the rare case of TWO instances in the SAME launch+game +/// sharing one profile name: the first (dup_idx 0) keeps the bare per-profile +/// prefix (so the common one-instance-per-profile case is unchanged and stays +/// reusable), and each additional same-profile sibling gets a `-p` suffix so +/// they don't collide on — and file-lock — one prefix. +pub fn get_prefix_path(cfg: &SplituxConfig, profname: &str, game: usize, dup_idx: usize) -> PathBuf { let base = match cfg.proton_separate_pfxs { true => profname.replace(['/', '\\'], "_"), false => "1".to_string(), @@ -28,11 +34,14 @@ pub fn get_prefix_path(cfg: &SplituxConfig, profname: &str, game: usize) -> Path // reuse the same profile name don't share — and fight over — one Wine prefix. // Game 0 keeps the legacy name, so existing single-game prefixes stay valid // (no re-init) and single-game is byte-identical. - let dir = if game == 0 { + let mut dir = if game == 0 { base } else { format!("{base}-g{game}") }; + if dup_idx > 0 { + dir = format!("{dir}-p{dup_idx}"); + } PATH_PARTY.join("prefixes").join(dir) } @@ -46,8 +55,9 @@ pub fn setup_env( cfg: &SplituxConfig, profname: &str, game: usize, + dup_idx: usize, ) { - let path_pfx = get_prefix_path(cfg, profname, game); + let path_pfx = get_prefix_path(cfg, profname, game, dup_idx); // Proton version to use let protonpath = match cfg.proton_version.is_empty() { @@ -138,6 +148,6 @@ pub fn uses_direct_proton(handler: &Handler) -> bool { } /// Get the Wine prefix user directory path for binding profile data -pub fn get_prefix_user_path(cfg: &SplituxConfig, profname: &str, game: usize) -> PathBuf { - get_prefix_path(cfg, profname, game).join("drive_c/users/steamuser") +pub fn get_prefix_user_path(cfg: &SplituxConfig, profname: &str, game: usize, dup_idx: usize) -> PathBuf { + get_prefix_path(cfg, profname, game, dup_idx).join("drive_c/users/steamuser") } diff --git a/src/util.rs b/src/util.rs index 8d782ee..4f39dcc 100644 --- a/src/util.rs +++ b/src/util.rs @@ -374,15 +374,15 @@ pub fn force_remove_dir_all(path: &std::path::Path) { /// launcher on startup (this used to `?`-propagate a PermissionDenied straight /// into `main`'s `.unwrap()`). pub fn clear_tmp() -> Result<(), Box> { - let tmp = PATH_PARTY.join("tmp"); - - if !tmp.exists() { - return Ok(()); - } - - let _ = fuse_overlayfs_unmount_gamedirs(); - force_remove_dir_all(&tmp); - + // Reap ONLY dead-pid orphan launch scratch (pid-aware). A concurrent LIVE + // session's tmp/ — with its mounted game-N overlays and the player-save + // upperdir — MUST be left intact. The previous unconditional + // force_remove_dir_all(tmp/) `rm -rf`'d the WHOLE scratch tree, and since the + // unmount step was narrowed to this process's own namespace it would delete + // straight through a running session's overlay mount into the player's saves + // (data loss) and yank the mount out from under the live game. Orphan reaping + // gives the same startup-cleanup without touching live sessions. + reap_orphan_launch_scratch(); Ok(()) } diff --git a/src/wm.rs b/src/wm.rs index b46b143..4ddf89d 100644 --- a/src/wm.rs +++ b/src/wm.rs @@ -38,14 +38,6 @@ pub struct LayoutContext { /// Maps spawn index to region index (for custom layout ordering) /// e.g., [1, 0] means window 0 goes to region 1, window 1 goes to region 0 pub instance_to_region: Vec, - /// Whether the launch expressed an explicit per-instance display assignment - /// (`--display`, a GUI/TUI display pick, or any multi-monitor sizing). When - /// true, the fullscreen placement path HONORS each instance's `monitor` - /// exactly — two instances targeting one output share it and tile - /// side-by-side. When false (a bare launch with no display intent), it - /// auto-spreads colliding fullscreen windows onto free outputs so they don't - /// stack invisibly. See `NiriManager::position_windows_fullscreen`. - pub displays_assigned: bool, /// The launch is bypassing the nested gamescope compositor for a single /// local seat (`cfg.disable_gamescope`). The game window is then a plain /// host-compositor surface, NOT a gamescope window, so the niri backend diff --git a/src/wm/niri.rs b/src/wm/niri.rs index b748000..390bc59 100644 --- a/src/wm/niri.rs +++ b/src/wm/niri.rs @@ -235,28 +235,6 @@ impl NiriManager { /// what splitux-together needs to capture. Multiple instances on one /// monitor land in adjacent columns you scroll between; each is its own /// fullscreen window. - /// Pick a distinct output for a fullscreen window: prefer the instance's - /// assigned monitor, but if it's already taken by an earlier fullscreen - /// window, hand back the first still-free output (position order). Records the - /// choice in `used`. Returns the preferred name (sharing) only when every - /// output is already taken — i.e. more instances than monitors. - fn pick_distinct_output( - preferred: Option<&str>, - outputs: &[WmMonitor], - used: &mut Vec, - ) -> Option { - if let Some(p) = preferred - && !used.iter().any(|u| u == p) { - used.push(p.to_string()); - return Some(p.to_string()); - } - if let Some(free) = outputs.iter().find(|o| !used.iter().any(|u| u == &o.name)) { - used.push(free.name.clone()); - return Some(free.name.clone()); - } - preferred.map(|p| p.to_string()) - } - fn position_windows_fullscreen( &self, ctx: &LayoutContext, @@ -267,30 +245,22 @@ impl NiriManager { windows.len() ); - // Every niri output, position-sorted. Used to spill colliding instances - // onto distinct displays (see pick_distinct_output). + // Every niri output, position-sorted (used for the fullscreen-size check). let outputs = self.get_monitors().unwrap_or_default(); - let mut used_outputs: Vec = Vec::new(); - - // Pass 1: resolve each window's ACTUAL target output. The instance's - // assigned monitor is the PREFERRED output. When the launch EXPLICITLY - // assigned displays (`--display` / a GUI/TUI pick), honor that exactly — - // two windows targeting one output share it and STACK (fullscreen each) - // below. Otherwise (a bare fullscreen launch with no display intent), - // pick_distinct_output spills a colliding instance onto the next FREE - // display so independent fullscreen windows don't stack invisibly — only - // sharing an output once there are more instances than outputs. + + // Pass 1: each window's target = its instance's assigned/preferred monitor, + // ALWAYS honored. Two instances on the same monitor STACK as niri columns + // (each fullscreen; scroll to swap). We deliberately do NOT auto-spread a + // colliding instance onto another free output: that could relocate a + // window onto an UNINTENDED display — e.g. a virtual Sunshine output whose + // nested capture wedges the compositor — which is far worse than stacking. + // To spread across monitors on purpose, pass --display per instance. let mut assigned: Vec<(usize, Option)> = Vec::with_capacity(windows.len()); for (i, window) in windows.iter().enumerate() { - let preferred = self.resolve_instance_monitor(ctx, i); - let target = if ctx.displays_assigned { - preferred.clone() - } else { - Self::pick_distinct_output(preferred.as_deref(), &outputs, &mut used_outputs) - }; + let target = self.resolve_instance_monitor(ctx, i); println!( - "[splitux] wm::niri - Fullscreen window {}: id={} app_id={} -> monitor {:?} (preferred {:?}, assigned={})", - i, window.id, window.app_id, target, preferred, ctx.displays_assigned + "[splitux] wm::niri - Fullscreen window {}: id={} app_id={} -> monitor {:?}", + i, window.id, window.app_id, target ); assigned.push((i, target)); } @@ -445,7 +415,7 @@ impl NiriManager { if group.len() == 1 { self.ensure_window_fullscreen(&windows[group[0]], target.as_deref(), &outputs)?; } else { - self.tile_windows_on_monitor(ctx, &group, target.as_deref(), ctx.preset.id, &outputs)?; + self.tile_windows_on_monitor(ctx, &group, target.as_deref(), ctx.preset.id, &outputs, &windows)?; } } Ok(()) @@ -552,11 +522,14 @@ impl NiriManager { target: Option<&str>, preset_id: &str, outputs: &[WmMonitor], + windows: &[NiriWindow], ) -> WmResult<()> { let plan = plan_tiling_layout(preset_id, group.len()); - // Step 1: move this group onto its monitor + ensure tiled. - let windows = self.get_gamescope_windows()?; + // Step 1: move this group onto its monitor + ensure tiled. Index the SAME + // window snapshot the caller built `group` from — re-fetching here and + // indexing by global index would mis-map if niri reordered its window + // list (focus/MRU) between snapshots, tiling the wrong window. for &gi in group { if let Some(win) = windows.get(gi) { self.niri_action("focus-window", &["--id", &win.id.to_string()])?; @@ -580,8 +553,8 @@ impl NiriManager { } } - // Step 2: apply the plan, mapping group-local column indices → global. - let windows = self.get_gamescope_windows()?; + // Step 2: apply the plan, mapping group-local column indices → global, + // against the same stable snapshot. for column in &plan.columns { let width = format!("{}%", column.width_percent); let actual: Vec = column diff --git a/src/wm/pure/layout.rs b/src/wm/pure/layout.rs index 10b1752..f1bb9be 100644 --- a/src/wm/pure/layout.rs +++ b/src/wm/pure/layout.rs @@ -47,8 +47,8 @@ pub fn plan_tiling_layout(preset_id: &str, window_count: usize) -> TilingPlan { } LayoutType::Grid => { - // 2x2 grid: 2 columns with 2 stacked windows each - // Window ordering depends on preset + // 2x2 grid: 2 columns with 2 stacked windows each. + // Window ordering depends on preset. let order: [usize; 4] = match preset_id { // 4p_columns: P1/P2 left column, P3/P4 right column "4p_columns" => [0, 1, 2, 3], @@ -56,18 +56,26 @@ pub fn plan_tiling_layout(preset_id: &str, window_count: usize) -> TilingPlan { _ => [0, 2, 1, 3], }; - TilingPlan { - columns: vec![ - TilingColumn { - windows: vec![order[0], order[1]], - width_percent: 50, - }, - TilingColumn { - windows: vec![order[2], order[3]], - width_percent: 50, - }, - ], + // Place ONLY the windows that exist: a multi-monitor layout can hand + // this monitor a subgroup with window_count < 4, in which case the + // fixed 4-index grid referenced absent indices and stacked everything + // into the left column (right half empty). Distribute the present + // windows across two columns (single full-width column for one). + let present: Vec = order.iter().copied().filter(|&w| w < window_count).collect(); + let mid = present.len().div_ceil(2); + let left = present[..mid].to_vec(); + let right = present[mid..].to_vec(); + let mut columns = vec![TilingColumn { + windows: left, + width_percent: if right.is_empty() { 100 } else { 50 }, + }]; + if !right.is_empty() { + columns.push(TilingColumn { + windows: right, + width_percent: 50, + }); } + TilingPlan { columns } } } } From 261b1a54c49780a982116936235e0edf34fa8335 Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Thu, 2 Jul 2026 20:57:09 -0400 Subject: [PATCH 2/7] together: wire ALL input devices to every together seat Gamepad-type seats used to be the only ones getting the seat pad wired (a phantom-controller precaution) which left kbm-type together seats padless in-game. All input now goes to all together seats. --- src/launch/pipelines/build_cmds.rs | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index 55d44a4..ed5bfb8 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -540,25 +540,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 { From 8fd9f33f297343f825950442772016b27a8e7c00 Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Thu, 2 Jul 2026 20:57:20 -0400 Subject: [PATCH 3/7] launch: per-instance gamescope bypass in mixed launches disable_gamescope used to gate on the launch as a whole (single lone local seat only). Decide it per instance instead, keyed on monitor sharing: a local instance alone on its own monitor bypasses the nested gamescope (kills the double-compositor scan-line artifact on high-refresh panels) even when the same launch also runs a together instance elsewhere, which keeps gamescope for its PipeWire capture. Window management follows: a bypassed instance is a native host-compositor surface, so the niri backend resolves window->instance ownership through the per-instance systemd scope (splitux--g-i.scope) instead of trusting list order, relaxes matching to scoped-ownership, and treats placement as best-effort rather than hard-aborting the launch. --- src/launch/pipelines/build_cmds.rs | 46 ++++++++++++++++----- src/launch/pipelines/execute.rs | 36 +++++++++++++---- src/wm.rs | 15 ++++--- src/wm/niri.rs | 65 +++++++++++++++++++++++------- 4 files changed, 124 insertions(+), 38 deletions(-) diff --git a/src/launch/pipelines/build_cmds.rs b/src/launch/pipelines/build_cmds.rs index ed5bfb8..5b364cf 100644 --- a/src/launch/pipelines/build_cmds.rs +++ b/src/launch/pipelines/build_cmds.rs @@ -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 = { + 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]; @@ -149,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 { @@ -193,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-.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"); diff --git a/src/launch/pipelines/execute.rs b/src/launch/pipelines/execute.rs index 1edf3f8..52bac09 100644 --- a/src/launch/pipelines/execute.rs +++ b/src/launch/pipelines/execute.rs @@ -241,17 +241,39 @@ pub fn launch_game( println!("[splitux] Layout: instance_to_region = {:?}", instance_to_region); // Gamescope-bypass: mirror build_cmds' per-instance decision EXACTLY so the WM - // agrees with the command that was actually built. build_cmds gates on the + // 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. Use the - // same seat-list emptiness (instance 0, since this only engages for len==1). - let no_gamescope = instances.len() == 1 - && together_devices.first().map(|s| s.is_empty()).unwrap_or(true) - && !handlers[instances[0].game].disable_bwrap - && handlers[instances[0].game].effective_disable_gamescope(cfg); + // 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 = { + 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(), diff --git a/src/wm.rs b/src/wm.rs index 4ddf89d..86917e0 100644 --- a/src/wm.rs +++ b/src/wm.rs @@ -38,12 +38,15 @@ pub struct LayoutContext { /// Maps spawn index to region index (for custom layout ordering) /// e.g., [1, 0] means window 0 goes to region 1, window 1 goes to region 0 pub instance_to_region: Vec, - /// The launch is bypassing the nested gamescope compositor for a single - /// local seat (`cfg.disable_gamescope`). The game window is then a plain - /// host-compositor surface, NOT a gamescope window, so the niri backend - /// matches our scoped window without requiring the gamescope marker and - /// never hard-aborts the launch on a positioning miss (the game is already - /// rendering — placement is best-effort). No effect on the normal path. + /// At least one instance in this launch is bypassing the nested gamescope + /// compositor (`cfg.disable_gamescope`) — e.g. a local instance alone on its + /// own monitor, possibly alongside a together instance elsewhere that still + /// needs gamescope for its PipeWire capture. A bypassed instance's game + /// window is a plain host-compositor surface, NOT a gamescope window, so the + /// niri backend relaxes matching to scoped-ownership instead of requiring the + /// gamescope marker, and never hard-aborts the launch on a positioning miss + /// (the game is already rendering — placement is best-effort). No effect on + /// a launch where every instance keeps its nested gamescope. pub no_gamescope: bool, } diff --git a/src/wm/niri.rs b/src/wm/niri.rs index 390bc59..d03042f 100644 --- a/src/wm/niri.rs +++ b/src/wm/niri.rs @@ -12,6 +12,7 @@ struct NiriWindow { id: u64, app_id: String, is_floating: bool, + pid: Option, } /// Whether `pid`'s process is a gamescope binary. Used to identify gamescope @@ -27,13 +28,34 @@ fn pid_is_gamescope(pid: u64) -> bool { .unwrap_or(false) } +/// Which instance (index into `ctx.instances`) owns `pid`, via its dedicated +/// systemd scope (`splitux--g-i.scope` — see +/// `launch::operations::scope::wrap_command`). This is what makes "window i +/// belongs to instance i" an actual invariant rather than an assumption about +/// spawn order: a gamescope-bypassed instance's native Wayland surface can +/// appear in niri's window list before or after a nested gamescope instance's +/// surface, so list position alone can't be trusted once a launch mixes both +/// kinds of instance. `None` when scoping is disabled or no instance's scope +/// owns this pid (caller falls back to positional order in that case). +fn resolve_window_instance(ctx: &LayoutContext, pid: u64) -> Option { + if !crate::launch::scope::enabled() { + return None; + } + let cgroup = std::fs::read_to_string(format!("/proc/{pid}/cgroup")).ok()?; + let ns = crate::paths::launch_ns(); + ctx.instances.iter().enumerate().find_map(|(i, inst)| { + let unit = format!("splitux-{ns}-g{}-i{i}.scope", inst.game); + cgroup.contains(&unit).then_some(i) + }) +} + pub struct NiriManager { target_monitor: Option, bar_manager: StatusBarManager, - /// Set from `LayoutContext::no_gamescope` in `setup`. When true the launch - /// bypassed gamescope (single local seat) and the game window is a plain - /// host surface, so window matching relaxes the gamescope marker and - /// positioning misses are non-fatal. + /// Set from `LayoutContext::no_gamescope` in `setup`. When true, at least + /// one instance in this launch bypassed gamescope, so window matching + /// relaxes the gamescope marker (falling back to scope ownership) and + /// positioning misses are non-fatal for the whole launch. no_gamescope: bool, } @@ -152,8 +174,12 @@ impl NiriManager { Err(format!("Monitor '{}' not found after {} retries", connector_name, max_retries).into()) } - /// Get list of gamescope windows belonging to THIS launch. - fn get_gamescope_windows(&self) -> WmResult> { + /// Get list of gamescope windows belonging to THIS launch, ordered to match + /// `ctx.instances` (window i ↔ instance i) via each window's owning scope — + /// see [`resolve_window_instance`]. Windows whose owning instance can't be + /// resolved (scoping disabled) keep their original relative order, so + /// behavior is unchanged for launches that were never scope-identifiable. + fn get_gamescope_windows(&self, ctx: &LayoutContext) -> WmResult> { let response = self.niri_msg(&["windows"])?; let windows: serde_json::Value = serde_json::from_str(&response) .map_err(|e| format!("Failed to parse windows: {}", e))?; @@ -189,11 +215,14 @@ impl NiriManager { // backed by a gamescope binary is ours regardless of app_id. let pid = win["pid"].as_u64(); let pid_is_gs = pid.is_some_and(pid_is_gamescope); - // Gamescope-bypass launch: the game runs directly under niri with - // NO gamescope process, so the gamescope marker never matches. - // Ownership alone (the window's pid is in our launch scope) then - // identifies our window — there's exactly one instance in this - // mode, so the scoped match can't grab anything but the game. + // Gamescope-bypass instance: that instance's game runs directly + // under niri with NO gamescope process, so the gamescope marker + // never matches its window. Ownership alone (the window's pid is + // in our launch scope) identifies it instead — safe even in a + // mixed launch (bypass + nested gamescope side by side) because + // this only widens the candidate set to "every window in our + // scope"; the sort below (`resolve_window_instance`) is what + // actually attributes each window to its owning instance. // Requires `scoped` (else `is_mine` is unconditionally true and // we'd grab every window on the desktop). let is_gamescope = (self.no_gamescope && scoped) @@ -205,10 +234,16 @@ impl NiriManager { id, app_id: app_id.to_string(), is_floating: win["is_floating"].as_bool().unwrap_or(false), + pid, }); } } } + result.sort_by_key(|w| { + w.pid + .and_then(|pid| resolve_window_instance(ctx, pid)) + .unwrap_or(usize::MAX) + }); Ok(result) } @@ -371,7 +406,7 @@ impl NiriManager { /// Position all gamescope windows according to layout using tiled mode fn position_windows(&self, ctx: &LayoutContext) -> WmResult<()> { - let windows = self.get_gamescope_windows()?; + let windows = self.get_gamescope_windows(ctx)?; if windows.is_empty() { return Err("No gamescope windows found".into()); } @@ -466,7 +501,7 @@ impl NiriManager { } // Step 2: Apply tiling plan — re-fetch windows after tiling changes - let windows = self.get_gamescope_windows()?; + let windows = self.get_gamescope_windows(ctx)?; for (col_idx, column) in plan.columns.iter().enumerate() { let width = format!("{}%", column.width_percent); @@ -642,7 +677,7 @@ impl WindowManager for NiriManager { if let Err(e) = crate::wm::operations::poll::wait_for_windows( "niri", expected_count, - || self.get_gamescope_windows().unwrap_or_default().len(), + || self.get_gamescope_windows(ctx).unwrap_or_default().len(), ) { println!( "[splitux] wm::niri - game window not matched ({e}); leaving placement to \ @@ -658,7 +693,7 @@ impl WindowManager for NiriManager { println!("[splitux] wm::niri - Waiting for gamescope windows..."); crate::wm::operations::poll::wait_for_windows("niri", expected_count, || { - self.get_gamescope_windows().unwrap_or_default().len() + self.get_gamescope_windows(ctx).unwrap_or_default().len() })?; self.position_windows(ctx) From 84b2993ac798aa4c5e8da63b0d87b2fc594181c1 Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Thu, 2 Jul 2026 20:57:20 -0400 Subject: [PATCH 4/7] keen: inherit emu stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keen-emu failures (e.g. AddrInUse from a stale port squatter) died silently behind Stdio::null — surface them in the launch log. --- src/backend/keen.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/backend/keen.rs b/src/backend/keen.rs index b7e2438..3e371d4 100644 --- a/src/backend/keen.rs +++ b/src/backend/keen.rs @@ -139,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 From 05fa2ae5abf2fadfce4e6e907791a1d5fd713b93 Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Thu, 2 Jul 2026 20:57:20 -0400 Subject: [PATCH 5/7] cli: instant, side-effect-free --help/--version Handle top-level help/version before scope re-exec and monitor probing; subcommand help stays with clap. --- src/main.rs | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/src/main.rs b/src/main.rs index 84e408e..5d9238a 100755 --- a/src/main.rs +++ b/src/main.rs @@ -75,11 +75,26 @@ fn main() -> eframe::Result { } // Headless CLI: if a subcommand was given, handle it and exit before any GUI - // or process-scoping setup. No subcommand → fall through to the GUI. + // or process-scoping setup. No subcommand → fall through to the GUI. This + // also covers `splitux --help`/`--version` via clap's own + // per-subcommand help, so those must NOT be intercepted by the top-level + // checks below. if let Some(code) = cli::run_if_cli() { std::process::exit(code); } + // Top-level `--help`/`--version` (GUI-mode invocation, no subcommand): handle + // before any scope re-exec / monitor probing side effects, so both are + // instant and side-effect-free like every other CLI tool. + if std::env::args().any(|arg| arg == "--help" || arg == "-h") { + println!("{}", USAGE_TEXT); + std::process::exit(0); + } + if std::env::args().any(|arg| arg == "--version" || arg == "-V") { + println!("splitux {}", env!("SPLITUX_VERSION")); + std::process::exit(0); + } + // Re-exec into a dedicated systemd scope so every process we launch lives in // a cgroup bound to this splitux instance — killing splitux cascades to the // whole game tree. No-op if already scoped or systemd-user is unavailable. @@ -106,11 +121,6 @@ fn main() -> eframe::Result { let args: Vec = std::env::args().collect(); - if std::env::args().any(|arg| arg == "--help") { - println!("{}", USAGE_TEXT); - std::process::exit(0); - } - if std::env::args().any(|arg| arg == "--kwin") { use crate::wm::{KWinManager, NestedSession}; @@ -283,14 +293,24 @@ fn main() -> eframe::Result { ) } -static USAGE_TEXT: &str = r#" -{} -Usage: splitux [OPTIONS] +static USAGE_TEXT: &str = r#"Usage: splitux [OPTIONS] + splitux [ARGS] + +Run with no options to open the GUI. -Options: +GUI options: --exec Execute the specified executable in splitscreen. If this isn't specified, Splitux will launch in the regular GUI mode. --args [args] Specify arguments for the executable to be launched with. Must be quoted if containing spaces. --fullscreen Start the GUI in fullscreen mode --kwin Launch Splitux inside of a nested KWin session --hyprland Launch Splitux inside of a nested Hyprland session + -h, --help Print this help + -V, --version Print version + +Commands (headless CLI / TUI — run `splitux --help` for details): + list Inspect the machine: games, profiles, inputs, monitors, layouts + launch Launch a full session headlessly (no GUI) + save-session Save a session as a reusable, pinned template + tui Interactive terminal UI (keyboard-driven GUI replacement) + completions Print a shell completion script "#; From 4821d8e6790cb3d748df2a26a08889284644c5ca Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Thu, 2 Jul 2026 20:58:31 -0400 Subject: [PATCH 6/7] v0.20.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e053909..2909041 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4413,7 +4413,7 @@ dependencies = [ [[package]] name = "splitux" -version = "0.19.0" +version = "0.20.0" dependencies = [ "clap", "clap_complete", diff --git a/Cargo.toml b/Cargo.toml index 64e4419..be04825 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "splitux" -version = "0.19.0" +version = "0.20.0" edition = "2024" [dependencies] From 671e113417dc3d864a943ca321e0a901046853f4 Mon Sep 17 00:00:00 2001 From: gabrielgad Date: Fri, 3 Jul 2026 15:34:13 -0400 Subject: [PATCH 7/7] ci: pilot ephemeral podman runners for build/build-flatpak Moves splitux's self-hosted CI jobs off the shared bare-metal `splitux` runner fleet onto dedicated, ephemeral, per-job podman containers (label splitux-ci) - Phase 2 of containerizing the org's runner fleet (following splitux-together's Phase 1 pilot). gamescope-splitux and gbe_fork-splitux's Linux jobs keep using the shared `splitux` label, unaffected. Design/rationale in ~/ssh-workspace/ci-runners/README.md on the lab. --- .github/workflows/build.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bbf71c6..576b05a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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') @@ -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'