diff --git a/Cargo.lock b/Cargo.lock index 034123f..88d1080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -132,6 +132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "version_check", @@ -840,6 +841,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -2339,7 +2360,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2614,6 +2635,15 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" +dependencies = [ + "spin 0.5.2", +] + [[package]] name = "nohash-hasher" version = "0.2.0" @@ -2989,6 +3019,9 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "portable-atomic", +] [[package]] name = "openssl" @@ -3708,6 +3741,35 @@ dependencies = [ "webpki-roots 1.0.6", ] +[[package]] +name = "rhai" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "no-std-compat", + "num-traits", + "once_cell", + "rhai_codegen", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ring" version = "0.17.14" @@ -4093,6 +4155,17 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + [[package]] name = "smithay-client-toolkit" version = "0.19.2" @@ -4185,6 +4258,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -4363,6 +4442,7 @@ dependencies = [ "rand 0.8.5", "regex", "reqwest 0.12.28", + "rhai", "serde", "serde_json", "sha1", @@ -4477,6 +4557,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + [[package]] name = "thiserror" version = "1.0.69" @@ -4559,6 +4645,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.11.4" diff --git a/Cargo.toml b/Cargo.toml index fe821b6..37f3d78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ aes = "0.8" steam-cdn = "1.0.0" libc = "0.2" regex = "1" +rhai = { version = "1", features = ["sync"] } [patch.crates-io] steam-cdn = { path = "vendor/steam-cdn" } diff --git a/src/infra/logging/session.rs b/src/infra/logging/session.rs index 467a8b0..aa6f847 100644 --- a/src/infra/logging/session.rs +++ b/src/infra/logging/session.rs @@ -76,6 +76,8 @@ pub struct LaunchVerification { pub steam_runtime_policy: String, pub steam_runtime_source: String, // "default", "auto", "override" pub windows_steam_discovery_enabled: bool, + pub protonfixes_routed: bool, + pub rhai_fixup_applied: Option, pub log_head: Vec, pub log_tail: Vec, } diff --git a/src/infra/runners/tests.rs b/src/infra/runners/tests.rs index a82cd44..3a1235d 100644 --- a/src/infra/runners/tests.rs +++ b/src/infra/runners/tests.rs @@ -33,6 +33,7 @@ mod tests { proton_path: Some("/tmp/proton".to_string()), target_architecture: crate::models::ExecutableArchitecture::X86_64, dll_resolutions: Vec::new(), + fixup_result: None, verification_ptr: std::ptr::null_mut(), } } @@ -77,6 +78,8 @@ mod tests { let dxvk_dll = runner_path.join("files/lib/wine/dxvk/d3d11.dll"); fs::create_dir_all(dxvk_dll.parent().unwrap()).unwrap(); fs::write(&dxvk_dll, "fake dll content").unwrap(); + fs::create_dir_all(runner_path.join("bin")).unwrap(); + fs::write(runner_path.join("bin/wine64"), "").unwrap(); let app = LibraryGame { app_id: 123, @@ -112,6 +115,7 @@ mod tests { proton_path: Some(runner_path.to_string_lossy().to_string()), target_architecture: crate::models::ExecutableArchitecture::X86_64, dll_resolutions: Vec::new(), + fixup_result: None, verification_ptr: std::ptr::null_mut(), }; @@ -145,7 +149,8 @@ mod tests { let tmp = tempdir().unwrap(); let proton_path = tmp.path().join("proton"); - fs::create_dir_all(&proton_path).unwrap(); + fs::create_dir_all(proton_path.join("bin")).unwrap(); + fs::write(proton_path.join("bin/wine64"), "").unwrap(); ctx.proton_path = Some(proton_path.to_string_lossy().to_string()); // build_env should succeed without real filesystem diff --git a/src/infra/runners/trait.rs b/src/infra/runners/trait.rs index 9c1d3a8..540c0f6 100644 --- a/src/infra/runners/trait.rs +++ b/src/infra/runners/trait.rs @@ -13,6 +13,7 @@ pub struct LaunchContext { pub proton_path: Option, pub target_architecture: crate::models::ExecutableArchitecture, pub dll_resolutions: Vec, + pub fixup_result: Option, pub verification_ptr: *mut crate::infra::logging::LaunchVerification, // HACK: for Runner to write diagnostics } diff --git a/src/infra/runners/wine_tkg.rs b/src/infra/runners/wine_tkg.rs index 075bcbe..b35cc17 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -228,13 +228,29 @@ impl Runner for WineTkgRunner { let steam_runner = if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { ctx.launcher_config.steam_runtime_runner.clone() } else { - active_runner.clone() + let discovered = crate::utils::resolve_runner("wine-tkg", &library_root); + if !discovered.exists() { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + "Steam Runtime Runner is not set and no wine-tkg/plain Wine runner was found. Set “Steam Runtime Runner” in Settings." + )); + } + discovered }; tracing::info!("Using runner for background Steam: {}", steam_runner.display()); - let mut steam_cmd = crate::utils::build_runner_command(&steam_runner) - .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Steam Runtime runner path: {}", steam_runner.display())).with_source(e))?; + let mut steam_cmd = match crate::utils::classify_runner(&steam_runner) { + crate::utils::RunnerKind::PlainWine { .. } => crate::utils::build_runner_command(&steam_runner) + .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Steam Runtime runner path: {}", steam_runner.display())).with_source(e))?, + crate::utils::RunnerKind::Proton { bundled_wine64: Some(wine64), .. } => Command::new(wine64), + crate::utils::RunnerKind::Proton { bundled_wine64: None, .. } => { + return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Steam Runtime Runner {} is a Proton tree but no bundled wine64 was found", steam_runner.display()))); + } + crate::utils::RunnerKind::Unknown => { + return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Unknown Steam Runtime Runner: {}", steam_runner.display()))); + } + }; steam_cmd.current_dir(&prefix_steam_dir); steam_cmd .arg("C:\\Program Files (x86)\\Steam\\steam.exe") @@ -475,6 +491,20 @@ impl Runner for WineTkgRunner { ) )); } + let game_runner_kind = crate::utils::classify_runner(&active_runner_path); + if matches!(game_runner_kind, crate::utils::RunnerKind::Unknown) { + return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Unknown Compatibility Layer path: {}", active_runner_path.display()))); + } + unsafe { + if !ctx.verification_ptr.is_null() { + (*ctx.verification_ptr).protonfixes_routed = matches!(game_runner_kind, crate::utils::RunnerKind::Proton { has_protonfixes: true, .. }); + if let Some(result) = &ctx.fixup_result { + if !result.extra_env.is_empty() || !result.extra_dll_overrides.is_empty() || !result.actions_log.is_empty() { + (*ctx.verification_ptr).rhai_fixup_applied.get_or_insert_with(|| ctx.app.app_id.to_string()); + } + } + } + } let _components = crate::utils::detect_runner_components( &active_runner_path, Some(&effective_game_prefix), @@ -525,6 +555,19 @@ impl Runner for WineTkgRunner { Some(&game_working_dir), strict_dxvk, ); + if let Some(fixup) = &ctx.fixup_result { + for fragment in &fixup.extra_dll_overrides { + if !fragment.trim().is_empty() { + if !dll_overrides.is_empty() { + dll_overrides.push(';'); + } + dll_overrides.push_str(fragment.trim()); + } + } + for action in &fixup.actions_log { + tracing::info!("rhai fixup action: {}", action); + } + } // Enhance overrides with resolved DLL providers for res in &ctx.dll_resolutions { @@ -550,6 +593,11 @@ impl Runner for WineTkgRunner { tracing::info!("Final WINEDLLOVERRIDES: {}", dll_overrides); env.insert("WINEDLLOVERRIDES".to_string(), dll_overrides); + if let Some(fixup) = &ctx.fixup_result { + for (key, value) in &fixup.extra_env { + env.insert(key.clone(), value.clone()); + } + } let steam_prefix_mode = ctx.user_config.as_ref() .map(|c| c.steam_prefix_mode.clone()) @@ -848,12 +896,25 @@ impl Runner for WineTkgRunner { .unwrap_or(ctx.launcher_config.proton_version.as_str()) }; let active_runner = crate::utils::resolve_runner(proton, &library_root); + let game_runner_kind = crate::utils::classify_runner(&active_runner); + if matches!(game_runner_kind, crate::utils::RunnerKind::Unknown) { + return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Unknown Compatibility Layer path: {}", active_runner.display()))); + } let mut spec = CommandSpec::default(); // Build the base command (handles 'proton run' wrapper and directory resolution) - let base_cmd = crate::utils::build_runner_command(&active_runner) - .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Compatibility Layer path: {}", active_runner.display())).with_source(e))?; + let base_cmd = match &game_runner_kind { + crate::utils::RunnerKind::Proton { has_protonfixes: false, bundled_wine64: Some(wine64), .. } => Command::new(wine64), + crate::utils::RunnerKind::Proton { has_protonfixes: false, bundled_wine64: None, .. } => { + return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Compatibility Layer {} has a proton script but no protonfixes and no bundled wine64 for fallback", active_runner.display()))); + } + // Real Proton must go through the proton script so bundled protonfixes and + // pressure-vessel/bootstrap behavior run naturally. Environment construction is + // shared with Wine so DLL override/WINEDLLPATH handling remains intact. + _ => crate::utils::build_runner_command(&active_runner) + .map_err(|e| LaunchError::new(LaunchErrorKind::Runner, format!("Invalid Compatibility Layer path: {}", active_runner.display())).with_source(e))?, + }; spec.program = base_cmd.get_program().into(); spec.args = base_cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); @@ -929,4 +990,3 @@ impl Runner for WineTkgRunner { cmd.spawn().map_err(|e| LaunchError::new(LaunchErrorKind::Process, "failed to spawn runner process").with_source(anyhow!(e))) } } - diff --git a/src/launch/fixups/mod.rs b/src/launch/fixups/mod.rs new file mode 100644 index 0000000..fe8cb9a --- /dev/null +++ b/src/launch/fixups/mod.rs @@ -0,0 +1,105 @@ +use anyhow::{Context, Result}; +use rhai::{Engine, Scope}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct FixupResult { + pub extra_env: HashMap, + pub extra_dll_overrides: Vec, + pub actions_log: Vec, +} + +#[derive(Debug, Clone)] +pub struct FixupContext { + pub app_id: u32, + pub app_name: String, + pub install_dir: String, + pub wineprefix: String, + pub target_architecture: String, + pub result: FixupResult, +} + +impl FixupContext { + pub fn new(app_id: u32, app_name: String, install_dir: String, wineprefix: String, target_architecture: String) -> Self { + Self { app_id, app_name, install_dir, wineprefix, target_architecture, result: FixupResult::default() } + } + pub fn set_env(&mut self, key: &str, value: &str) { self.result.extra_env.insert(key.to_string(), value.to_string()); } + pub fn add_dll_override(&mut self, fragment: &str) { self.result.extra_dll_overrides.push(fragment.to_string()); } + pub fn log(&mut self, message: &str) { self.result.actions_log.push(message.to_string()); } +} + +pub const SEED_SCRIPTS: &[(&str, &str)] = &[ + ("227300.rhai", include_str!("seed_scripts/227300.rhai")), + ("359550.rhai", include_str!("seed_scripts/359550.rhai")), + ("271590.rhai", include_str!("seed_scripts/271590.rhai")), + ("1151640.rhai", include_str!("seed_scripts/1151640.rhai")), +]; + +pub fn fixups_dir() -> Result { Ok(crate::config::config_dir()?.join("fixups")) } + +pub fn seed_default_fixups() -> Result<()> { + let dir = fixups_dir()?; + std::fs::create_dir_all(&dir)?; + for (name, body) in SEED_SCRIPTS { + let path = dir.join(name); + if !path.exists() { std::fs::write(path, body)?; } + } + Ok(()) +} + +fn engine() -> Engine { + let mut engine = Engine::new(); + engine.register_type::(); + engine.register_get("app_id", |ctx: &mut FixupContext| ctx.app_id as i64); + engine.register_get("app_name", |ctx: &mut FixupContext| ctx.app_name.clone()); + engine.register_get("install_dir", |ctx: &mut FixupContext| ctx.install_dir.clone()); + engine.register_get("wineprefix", |ctx: &mut FixupContext| ctx.wineprefix.clone()); + engine.register_get("target_architecture", |ctx: &mut FixupContext| ctx.target_architecture.clone()); + engine.register_fn("set_env", FixupContext::set_env); + engine.register_fn("add_dll_override", FixupContext::add_dll_override); + engine.register_fn("log", FixupContext::log); + engine +} + +pub fn run_fixup_script(path: &Path, mut ctx: FixupContext) -> Result { + let script = std::fs::read_to_string(path).with_context(|| format!("reading fixup script {}", path.display()))?; + let mut scope = Scope::new(); + scope.push("ctx", ctx.clone()); + // Rhai evaluation is synchronous and scripts are tiny per-launch config snippets, so this + // intentionally runs inline in the async pipeline rather than offloading to a blocking pool. + engine().eval_with_scope::<()>(&mut scope, &script)?; + ctx = scope.get_value::("ctx").unwrap_or(ctx); + Ok(ctx.result) +} + +pub fn load_and_run_fixup(app_id: u32, ctx: FixupContext) -> Result> { + seed_default_fixups()?; + let path = fixups_dir()?.join(format!("{}.rhai", app_id)); + if !path.exists() { return Ok(None); } + let result = run_fixup_script(&path, ctx)?; + Ok(Some((path.file_name().unwrap().to_string_lossy().to_string(), result))) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn seed_scripts_execute() { + for (name, body) in SEED_SCRIPTS { + let mut p = std::env::temp_dir(); p.push(format!("steamflow_{}", name)); + std::fs::write(&p, body).unwrap(); + let res = run_fixup_script(&p, FixupContext::new(1,"Game".into(),"/g".into(),"/p".into(),"x86_64".into())).unwrap(); + assert!(!res.extra_env.is_empty() || !res.extra_dll_overrides.is_empty()); + assert!(!res.actions_log.is_empty()); + let _ = std::fs::remove_file(p); + } + } + #[test] + fn malformed_errors_without_panic() { + let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("bad.rhai"); + std::fs::write(&p, "let = ;").unwrap(); + assert!(run_fixup_script(&p, FixupContext::new(1,"Game".into(),"/g".into(),"/p".into(),"x86".into())).is_err()); + } +} diff --git a/src/launch/fixups/seed_scripts/1151640.rhai b/src/launch/fixups/seed_scripts/1151640.rhai new file mode 100644 index 0000000..c06aef7 --- /dev/null +++ b/src/launch/fixups/seed_scripts/1151640.rhai @@ -0,0 +1,2 @@ +ctx.set_env("WINE_FULLSCREEN_FSR", "1"); +ctx.log("Enable Wine FSR fullscreen scaling hint"); diff --git a/src/launch/fixups/seed_scripts/227300.rhai b/src/launch/fixups/seed_scripts/227300.rhai new file mode 100644 index 0000000..f4479ac --- /dev/null +++ b/src/launch/fixups/seed_scripts/227300.rhai @@ -0,0 +1,2 @@ +ctx.set_env("PBA_DISABLE_CRASHHANDLER", "1"); +ctx.log("Disabled process crash handler for Wine compatibility"); diff --git a/src/launch/fixups/seed_scripts/271590.rhai b/src/launch/fixups/seed_scripts/271590.rhai new file mode 100644 index 0000000..0ff6d79 --- /dev/null +++ b/src/launch/fixups/seed_scripts/271590.rhai @@ -0,0 +1,2 @@ +ctx.add_dll_override("dinput8=n,b"); +ctx.log("Prefer native dinput8 when present for launcher/mod compatibility"); diff --git a/src/launch/fixups/seed_scripts/359550.rhai b/src/launch/fixups/seed_scripts/359550.rhai new file mode 100644 index 0000000..97513bb --- /dev/null +++ b/src/launch/fixups/seed_scripts/359550.rhai @@ -0,0 +1,2 @@ +ctx.set_env("WINE_LARGE_ADDRESS_AWARE", "1"); +ctx.log("Enabled large-address-aware Wine compatibility mode"); diff --git a/src/launch/mod.rs b/src/launch/mod.rs index 1c0d8f7..e64eb41 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -2,6 +2,7 @@ pub mod pipeline; pub mod stages; pub mod validators; pub mod dll_provider_resolver; +pub mod fixups; #[cfg(test)] mod verification_tests; @@ -87,4 +88,3 @@ async fn download_steam_setup(path: &Path) -> Result<()> { std::fs::write(path, response)?; Ok(()) } - diff --git a/src/launch/pipeline.rs b/src/launch/pipeline.rs index 8f098e5..1f4ace0 100644 --- a/src/launch/pipeline.rs +++ b/src/launch/pipeline.rs @@ -94,6 +94,8 @@ pub struct PipelineContext { pub warnings: Vec, pub graphics_stack: GraphicsStackInfo, pub dll_resolutions: Vec, + pub fixup_result: Option, + pub fixup_script_name: Option, pub verification: crate::infra::logging::LaunchVerification, } @@ -118,6 +120,8 @@ impl PipelineContext { warnings: Vec::new(), graphics_stack: GraphicsStackInfo::default(), dll_resolutions: Vec::new(), + fixup_result: None, + fixup_script_name: None, verification: crate::infra::logging::LaunchVerification::default(), } } @@ -477,6 +481,7 @@ impl LaunchPipeline { pipeline.add_stage(Box::new(crate::launch::stages::resolve_game::ResolveGameStage)); pipeline.add_stage(Box::new(crate::launch::stages::resolve_profile::ResolveProfileStage)); pipeline.add_stage(Box::new(crate::launch::stages::resolve_components::ResolveComponentsStage)); + pipeline.add_stage(Box::new(crate::launch::stages::resolve_game_fixups::ResolveGameFixupsStage)); pipeline.add_stage(Box::new(crate::launch::stages::resolve_dll_providers::ResolveDllProvidersStage)); pipeline.add_stage(Box::new(crate::launch::stages::prepare_prefix::PreparePrefixStage)); pipeline.add_stage(Box::new(crate::launch::stages::build_environment::BuildEnvironmentStage)); @@ -1333,6 +1338,10 @@ impl LaunchPipeline { metadata.insert("steam_running_before_launch".to_string(), ctx.verification.steam_running_before_launch.to_string()); metadata.insert("steam_auto_start_attempted".to_string(), ctx.verification.steam_auto_start_attempted.to_string()); metadata.insert("steam_auto_start_failed".to_string(), ctx.verification.steam_auto_start_failed.to_string()); + metadata.insert("protonfixes_routed".to_string(), ctx.verification.protonfixes_routed.to_string()); + if let Some(ref script) = ctx.verification.rhai_fixup_applied { + metadata.insert("rhai_fixup_applied".to_string(), script.clone()); + } let _ = logger.info("launch_summary_concise", "Concise launch summary recorded".to_string(), None, metadata); } diff --git a/src/launch/stages/build_command.rs b/src/launch/stages/build_command.rs index e59cd7e..44f135c 100644 --- a/src/launch/stages/build_command.rs +++ b/src/launch/stages/build_command.rs @@ -21,6 +21,7 @@ impl PipelineStage for BuildCommandStage { proton_path: ctx.proton_path.clone(), target_architecture: ctx.target_architecture, dll_resolutions: ctx.dll_resolutions.clone(), + fixup_result: ctx.fixup_result.clone(), verification_ptr: &mut ctx.verification as *mut _, }; let spec = runner.build_command(&runner_ctx).await?; diff --git a/src/launch/stages/mod.rs b/src/launch/stages/mod.rs index f24fe75..d7e2158 100644 --- a/src/launch/stages/mod.rs +++ b/src/launch/stages/mod.rs @@ -1,6 +1,7 @@ pub mod resolve_game; pub mod resolve_profile; pub mod resolve_components; +pub mod resolve_game_fixups; pub mod resolve_dll_providers; pub mod prepare_prefix; pub mod build_environment; diff --git a/src/launch/stages/prepare_prefix.rs b/src/launch/stages/prepare_prefix.rs index 63e161e..3e0835f 100644 --- a/src/launch/stages/prepare_prefix.rs +++ b/src/launch/stages/prepare_prefix.rs @@ -24,6 +24,7 @@ impl PipelineStage for PreparePrefixStage { proton_path: ctx.proton_path.clone(), target_architecture: ctx.target_architecture, dll_resolutions: ctx.dll_resolutions.clone(), + fixup_result: ctx.fixup_result.clone(), verification_ptr: &mut ctx.verification as *mut _, }; runner.prepare_prefix(&runner_ctx).await?; diff --git a/src/launch/stages/resolve_game_fixups.rs b/src/launch/stages/resolve_game_fixups.rs new file mode 100644 index 0000000..c5547b3 --- /dev/null +++ b/src/launch/stages/resolve_game_fixups.rs @@ -0,0 +1,56 @@ +use async_trait::async_trait; +use crate::launch::pipeline::{PipelineStage, PipelineContext, LaunchError, LaunchErrorKind}; +use std::collections::HashMap; +use std::path::PathBuf; + +pub struct ResolveGameFixupsStage; + +#[async_trait] +impl PipelineStage for ResolveGameFixupsStage { + fn name(&self) -> &str { "ResolveGameFixups" } + + async fn execute(&self, ctx: &mut PipelineContext) -> std::result::Result<(), LaunchError> { + let config = ctx.launcher_config.as_ref().ok_or_else(|| LaunchError::new(LaunchErrorKind::Validation, "launcher_config missing"))?; + let app = ctx.app.as_ref().ok_or_else(|| LaunchError::new(LaunchErrorKind::Validation, "app missing"))?; + let library_root = PathBuf::from(&config.steam_library_path); + let proton = if let Some(forced) = config.game_configs.get(&ctx.app_id).and_then(|c| c.forced_proton_version.as_ref()) { + forced.as_str() + } else { + ctx.proton_path.as_deref().filter(|p| !p.is_empty()).unwrap_or(config.proton_version.as_str()) + }; + let runner = crate::utils::resolve_runner(proton, &library_root); + match crate::utils::classify_runner(&runner) { + crate::utils::RunnerKind::Unknown => return Err(LaunchError::new(LaunchErrorKind::Runner, format!("Unknown game runner: {}", runner.display()))), + crate::utils::RunnerKind::Proton { has_protonfixes: true, .. } => { + ctx.fixup_result = None; + ctx.fixup_script_name = None; + ctx.verification.protonfixes_routed = true; + if let Some(logger) = &ctx.logger { let _ = logger.info("protonfixes_routed", "Using Proton bundled protonfixes via proton run".into(), Some(self.name().into()), HashMap::new()); } + } + _ => { + ctx.verification.protonfixes_routed = false; + let store: crate::models::UserConfigStore = ctx.user_config.as_ref().map(|c| { let mut s = HashMap::new(); s.insert(ctx.app_id, c.clone()); s }).unwrap_or_default().into(); + let wineprefix = crate::utils::steam_wineprefix_for_game(config, ctx.app_id, &store); + let install_dir = app.install_path.clone().unwrap_or_default(); + let arch = match ctx.target_architecture { crate::models::ExecutableArchitecture::X86 => "x86", _ => "x86_64" }; + let fctx = crate::launch::fixups::FixupContext::new(ctx.app_id, app.name.clone(), install_dir, wineprefix.to_string_lossy().to_string(), arch.into()); + match crate::launch::fixups::load_and_run_fixup(ctx.app_id, fctx) { + Ok(Some((name, result))) => { + ctx.verification.rhai_fixup_applied = Some(name.clone()); + ctx.fixup_script_name = Some(name); + ctx.fixup_result = Some(result); + } + Ok(None) => { ctx.fixup_result = None; ctx.fixup_script_name = None; } + Err(e) => { + if let Some(logger) = &ctx.logger { + let mut m = HashMap::new(); m.insert("app_id".into(), ctx.app_id.to_string()); m.insert("error".into(), e.to_string()); + let _ = logger.error("fixup_script_error", "Rhai fixup failed; continuing without fixup".into(), Some(self.name().into()), m); + } + ctx.fixup_result = Some(crate::launch::fixups::FixupResult::default()); + } + } + } + } + Ok(()) + } +} diff --git a/src/ui.rs b/src/ui.rs index db3423b..0c8d8df 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2672,6 +2672,9 @@ impl eframe::App for SteamLauncher { } } }); + if let Some(warning) = crate::utils::validate_steam_runtime_runner_path(&self.launcher_config.steam_runtime_runner) { + ui.colored_label(egui::Color32::YELLOW, warning); + } ui.add_space(4.0); if ui.button("Install / Manage Windows Steam Runtime").clicked() { diff --git a/src/utils.rs b/src/utils.rs index 0074322..5c0043c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,6 +3,89 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::process::Command; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunnerKind { + /// A real Proton tree: has a `proton` script AND a bundled `protonfixes/` python package. + Proton { + proton_script: PathBuf, + bundled_wine64: Option, + has_protonfixes: bool, + }, + /// Plain Wine / wine-tkg: a `wine`/`wine64` binary with no Proton script and no + /// bundled protonfixes package. + PlainWine { + wine64: PathBuf, + wine32: Option, + }, + Unknown, +} + +pub fn classify_runner(runner_path: &Path) -> RunnerKind { + let root = if runner_path.is_file() { + if runner_path.file_name().and_then(|n| n.to_str()) == Some("proton") { + runner_path.parent().unwrap_or(runner_path).to_path_buf() + } else { + derive_runner_root(runner_path) + } + } else { + runner_path.to_path_buf() + }; + + let proton_script = root.join("proton"); + let has_protonfixes = ["protonfixes", "files/protonfixes", "dist/protonfixes"] + .iter() + .any(|p| root.join(p).is_dir()); + if proton_script.is_file() { + let bundled_wine64 = ["files/bin/wine64", "dist/bin/wine64", "bin/wine64", "files/bin/wine", "dist/bin/wine", "bin/wine"] + .iter() + .map(|p| root.join(p)) + .find(|p| p.is_file()); + return RunnerKind::Proton { proton_script, bundled_wine64, has_protonfixes }; + } + + if runner_path.is_file() { + if let Some(name) = runner_path.file_name().and_then(|n| n.to_str()) { + if name == "wine64" || name == "wine" { + let parent = runner_path.parent().unwrap_or(runner_path); + let wine32 = if name == "wine64" { + let p = parent.join("wine"); + p.is_file().then_some(p) + } else { + None + }; + return RunnerKind::PlainWine { wine64: runner_path.to_path_buf(), wine32 }; + } + } + } + + if runner_path.is_dir() && !runner_path.join("proton").is_file() { + for candidate in ["bin/wine64", "bin/wine"] { + let wine = runner_path.join(candidate); + if wine.is_file() { + let wine32 = runner_path.join("bin/wine"); + return RunnerKind::PlainWine { + wine64: wine, + wine32: wine32.is_file().then_some(wine32), + }; + } + } + } + + RunnerKind::Unknown +} + +pub fn validate_steam_runtime_runner_path(runner_path: &Path) -> Option { + if runner_path.as_os_str().is_empty() { + return None; + } + matches!(classify_runner(runner_path), RunnerKind::Unknown).then(|| { + format!( + "Steam Runtime Runner '{}' is not a recognized Proton or Wine runner.", + runner_path.display() + ) + }) +} + #[cfg(unix)] pub fn detect_active_wineserver_runtime(wineprefix: &Path) -> Option { let prefix_str = wineprefix.to_string_lossy().to_string(); @@ -1488,3 +1571,64 @@ pub fn steam_wineprefix_for_game( resolve_master_wineprefix() } } + +#[cfg(test)] +mod runner_kind_tests { + use super::*; + + #[test] + fn classifies_proton_tree_with_bundled_wine() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("proton"), "#!/usr/bin/env python3").unwrap(); + std::fs::create_dir_all(dir.path().join("files/protonfixes")).unwrap(); + std::fs::create_dir_all(dir.path().join("files/bin")).unwrap(); + std::fs::write(dir.path().join("files/bin/wine64"), "").unwrap(); + match classify_runner(dir.path()) { + RunnerKind::Proton { proton_script, bundled_wine64: Some(wine), has_protonfixes } => { + assert_eq!(proton_script, dir.path().join("proton")); + assert_eq!(wine, dir.path().join("files/bin/wine64")); + assert!(has_protonfixes); + } + other => panic!("unexpected classification: {other:?}"), + } + } + + #[test] + fn classifies_plain_wine_directory_and_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("bin")).unwrap(); + std::fs::write(dir.path().join("bin/wine64"), "").unwrap(); + assert!(matches!(classify_runner(dir.path()), RunnerKind::PlainWine { .. })); + assert!(matches!(classify_runner(&dir.path().join("bin/wine64")), RunnerKind::PlainWine { .. })); + } + + #[test] + fn classifies_unknown() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(classify_runner(dir.path()), RunnerKind::Unknown); + } + + #[test] + fn support_matrix_routing_decisions() { + let proton = tempfile::tempdir().unwrap(); + std::fs::write(proton.path().join("proton"), "").unwrap(); + std::fs::create_dir_all(proton.path().join("protonfixes")).unwrap(); + std::fs::create_dir_all(proton.path().join("files/bin")).unwrap(); + std::fs::write(proton.path().join("files/bin/wine64"), "").unwrap(); + let wine = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(wine.path().join("bin")).unwrap(); + std::fs::write(wine.path().join("bin/wine64"), "").unwrap(); + + let is_bg_bare = |p: &Path| match classify_runner(p) { + RunnerKind::Proton { bundled_wine64: Some(_), .. } | RunnerKind::PlainWine { .. } => true, + _ => false, + }; + let game_uses_protonfixes = |p: &Path| matches!(classify_runner(p), RunnerKind::Proton { has_protonfixes: true, .. }); + assert!(is_bg_bare(proton.path()) && game_uses_protonfixes(proton.path())); // row 1 + assert!(is_bg_bare(wine.path()) && !game_uses_protonfixes(wine.path())); // row 2 + assert!(is_bg_bare(proton.path()) && game_uses_protonfixes(proton.path())); // row 3 + assert!(is_bg_bare(wine.path()) && !game_uses_protonfixes(wine.path())); // row 4 + assert!(is_bg_bare(wine.path()) && game_uses_protonfixes(proton.path())); // row 5 + assert!(is_bg_bare(proton.path()) && !game_uses_protonfixes(wine.path())); // row 6 + } +}