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..645c2d1 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.19", features = ["sync"] } [patch.crates-io] steam-cdn = { path = "vendor/steam-cdn" } diff --git a/src/config.rs b/src/config.rs index e2b3a11..d20af9c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -95,6 +95,10 @@ pub async fn ensure_config_dirs() -> Result<()> { fs::create_dir_all(&config).await?; let images = opensteam_image_cache_dir()?; fs::create_dir_all(&images).await?; + + // Seed default Rhai fixups + let _ = crate::launch::fixups::seed_default_fixups(); + Ok(()) } diff --git a/src/infra/logging/session.rs b/src/infra/logging/session.rs index 467a8b0..5b3f0be 100644 --- a/src/infra/logging/session.rs +++ b/src/infra/logging/session.rs @@ -64,6 +64,8 @@ pub struct LaunchVerification { pub steam_running_before_launch: bool, pub steam_auto_start_attempted: bool, pub steam_auto_start_failed: bool, + pub protonfixes_routed: bool, + pub rhai_fixup_applied: Option, pub steam_api_initialized: Option, pub steam_ownership_confirmed: Option, pub steam_client_artifact: Option, // "local", "windows", "host" diff --git a/src/infra/runners/tests.rs b/src/infra/runners/tests.rs index a82cd44..1b100a9 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(), } } @@ -112,6 +113,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(), }; 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..0a11aba 100644 --- a/src/infra/runners/wine_tkg.rs +++ b/src/infra/runners/wine_tkg.rs @@ -225,16 +225,44 @@ impl Runner for WineTkgRunner { if steam_running { println!("✅ Steam already running in prefix — skipping spawn"); } else { - let steam_runner = if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { + let steam_runner_path = if !ctx.launcher_config.steam_runtime_runner.as_os_str().is_empty() { ctx.launcher_config.steam_runtime_runner.clone() } else { - active_runner.clone() + let r = crate::utils::resolve_runner("wine-tkg", &library_root); + if !r.exists() { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + "Steam Runtime Runner is not set and 'wine-tkg' was not found in common locations. Please set 'Steam Runtime Runner' in Settings." + )); + } + r }; - tracing::info!("Using runner for background Steam: {}", steam_runner.display()); + tracing::info!("Using runner for background Steam: {}", steam_runner_path.display()); + let steam_runner_kind = crate::utils::classify_runner(&steam_runner_path); + + let mut steam_cmd = match steam_runner_kind { + crate::utils::RunnerKind::PlainWine { wine64, .. } => { + Command::new(wine64) + } + crate::utils::RunnerKind::Proton { bundled_wine64, .. } => { + if let Some(wine64) = bundled_wine64 { + Command::new(wine64) + } else { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + format!("Proton tree at {} has no bundled wine64 binary for bare-wine invocation.", steam_runner_path.display()) + )); + } + } + crate::utils::RunnerKind::Unknown => { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + format!("Failed to classify Steam Runtime runner at {}", steam_runner_path.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))?; steam_cmd.current_dir(&prefix_steam_dir); steam_cmd .arg("C:\\Program Files (x86)\\Steam\\steam.exe") @@ -526,6 +554,16 @@ impl Runner for WineTkgRunner { strict_dxvk, ); + // Apply Rhai fixup DLL overrides + if let Some(res) = &ctx.fixup_result { + for fragment in &res.extra_dll_overrides { + if !dll_overrides.is_empty() { + dll_overrides.push(';'); + } + dll_overrides.push_str(fragment); + } + } + // Enhance overrides with resolved DLL providers for res in &ctx.dll_resolutions { if res.chosen_provider == crate::launch::dll_provider_resolver::DllProvider::GameLocal || @@ -775,6 +813,13 @@ impl Runner for WineTkgRunner { } } + // Apply Rhai fixup environment variables + if let Some(res) = &ctx.fixup_result { + for (key, val) in &res.extra_env { + env.insert(key.clone(), val.clone()); + } + } + if let Some(config) = &ctx.user_config { for (key, val) in &config.env_variables { env.insert(key.clone(), val.clone()); @@ -851,11 +896,36 @@ impl Runner for WineTkgRunner { 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))?; - spec.program = base_cmd.get_program().into(); - spec.args = base_cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + let kind = crate::utils::classify_runner(&active_runner); + match kind { + crate::utils::RunnerKind::Proton { has_protonfixes, .. } if has_protonfixes => { + // Build the base command (handles 'proton run' wrapper) + 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))?; + spec.program = base_cmd.get_program().into(); + spec.args = base_cmd.get_args().map(|s| s.to_string_lossy().to_string()).collect(); + } + crate::utils::RunnerKind::PlainWine { wine64, .. } => { + spec.program = wine64; + } + crate::utils::RunnerKind::Proton { bundled_wine64, .. } => { + // Stripped Proton build - use bare wine + if let Some(wine64) = bundled_wine64 { + spec.program = wine64; + } else { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + format!("Proton tree at {} has no bundled wine64 binary for bare-wine invocation.", active_runner.display()) + )); + } + } + crate::utils::RunnerKind::Unknown => { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + format!("Failed to classify Compatibility Layer at {}", active_runner.display()) + )); + } + } let install_dir = PathBuf::from( ctx.app.install_path diff --git a/src/launch/fixups/mod.rs b/src/launch/fixups/mod.rs new file mode 100644 index 0000000..630cfc9 --- /dev/null +++ b/src/launch/fixups/mod.rs @@ -0,0 +1,165 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use rhai::{Engine, Scope, CustomType}; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FixupResult { + pub extra_env: HashMap, + pub extra_dll_overrides: Vec, + pub actions_log: Vec, +} + +#[derive(Clone, CustomType)] +pub struct FixupContext { + pub app_id: u32, + pub app_name: String, + pub install_dir: String, + pub wineprefix: String, + pub target_architecture: String, + + #[rhai_type(skip)] + pub result: std::sync::Arc>, +} + +impl FixupContext { + pub fn set_env(&mut self, key: String, value: String) { + let mut res = self.result.lock().unwrap(); + res.extra_env.insert(key, value); + } + + pub fn add_dll_override(&mut self, fragment: String) { + let mut res = self.result.lock().unwrap(); + res.extra_dll_overrides.push(fragment); + } + + pub fn log(&mut self, message: String) { + let mut res = self.result.lock().unwrap(); + res.actions_log.push(message); + } +} + +pub fn run_fixup_script( + app_id: u32, + app_name: String, + install_dir: PathBuf, + wineprefix: PathBuf, + target_architecture: String, +) -> anyhow::Result> { + let config_dir = crate::config::config_dir()?; + let fixups_dir = config_dir.join("fixups"); + let script_path = fixups_dir.join(format!("{}.rhai", app_id)); + + if !script_path.exists() { + return Ok(None); + } + + let script = std::fs::read_to_string(&script_path)?; + execute_script(script, app_id, app_name, install_dir, wineprefix, target_architecture) +} + +fn execute_script( + script: String, + app_id: u32, + app_name: String, + install_dir: PathBuf, + wineprefix: PathBuf, + target_architecture: String, +) -> anyhow::Result> { + let mut engine = Engine::new(); + + engine.build_type::(); + engine.register_fn("set_env", FixupContext::set_env); + engine.register_fn("add_dll_override", FixupContext::add_dll_override); + engine.register_fn("log", FixupContext::log); + + let result = std::sync::Arc::new(std::sync::Mutex::new(FixupResult::default())); + let context = FixupContext { + app_id, + app_name, + install_dir: install_dir.to_string_lossy().to_string(), + wineprefix: wineprefix.to_string_lossy().to_string(), + target_architecture, + result: result.clone(), + }; + + let mut scope = Scope::new(); + scope.push("ctx", context); + + if let Err(e) = engine.run_with_scope(&mut scope, &script) { + tracing::error!("Error running fixup script for AppID {}: {}", app_id, e); + return Ok(None); + } + + let final_result = result.lock().unwrap().clone(); + Ok(Some(final_result)) +} + +pub fn seed_default_fixups() -> anyhow::Result<()> { + let config_dir = crate::config::config_dir()?; + let fixups_dir = config_dir.join("fixups"); + std::fs::create_dir_all(&fixups_dir)?; + + let seed_scripts = [ + (409710, include_str!("seeds/409710.rhai")), // BioShock Remastered + (22300, include_str!("seeds/22300.rhai")), // Fallout 3 + (22370, include_str!("seeds/22370.rhai")), // Fallout: New Vegas + (72850, include_str!("seeds/72850.rhai")), // Skyrim + ]; + + for (app_id, content) in seed_scripts { + let path = fixups_dir.join(format!("{}.rhai", app_id)); + if !path.exists() { + std::fs::write(path, content)?; + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_execute_script_success() { + let script = r#" + ctx.log("Starting fixup"); + ctx.set_env("PBA_DISABLE_CRASHHANDLER", "1"); + ctx.add_dll_override("d3d9=n,b"); + ctx.log("Fixup complete"); + "#.to_string(); + + let res = execute_script( + script, + 123, + "Test Game".to_string(), + PathBuf::from("/tmp"), + PathBuf::from("/tmp/pfx"), + "x86_64".to_string(), + ).unwrap().unwrap(); + + assert_eq!(res.extra_env.get("PBA_DISABLE_CRASHHANDLER").unwrap(), "1"); + assert_eq!(res.extra_dll_overrides[0], "d3d9=n,b"); + assert_eq!(res.actions_log[0], "Starting fixup"); + assert_eq!(res.actions_log[1], "Fixup complete"); + } + + #[test] + fn test_execute_script_malformed() { + let script = r#" + this is not rhai script + "#.to_string(); + + let res = execute_script( + script, + 123, + "Test Game".to_string(), + PathBuf::from("/tmp"), + PathBuf::from("/tmp/pfx"), + "x86_64".to_string(), + ).unwrap(); + + assert!(res.is_none()); + } +} diff --git a/src/launch/fixups/seeds/22300.rhai b/src/launch/fixups/seeds/22300.rhai new file mode 100644 index 0000000..39e5c8c --- /dev/null +++ b/src/launch/fixups/seeds/22300.rhai @@ -0,0 +1,3 @@ +ctx.log("Fallout 3 Fixup"); +ctx.set_env("WINE_LARGE_ADDRESS_AWARE", "1"); +ctx.add_dll_override("d3d9=n"); diff --git a/src/launch/fixups/seeds/22370.rhai b/src/launch/fixups/seeds/22370.rhai new file mode 100644 index 0000000..0eb2752 --- /dev/null +++ b/src/launch/fixups/seeds/22370.rhai @@ -0,0 +1,3 @@ +ctx.log("Fallout: New Vegas Fixup"); +ctx.set_env("WINE_LARGE_ADDRESS_AWARE", "1"); +ctx.add_dll_override("d3d9=n"); diff --git a/src/launch/fixups/seeds/409710.rhai b/src/launch/fixups/seeds/409710.rhai new file mode 100644 index 0000000..0adb690 --- /dev/null +++ b/src/launch/fixups/seeds/409710.rhai @@ -0,0 +1,3 @@ +ctx.log("BioShock Remastered Fixup"); +ctx.set_env("PBA_DISABLE_CRASHHANDLER", "1"); +ctx.add_dll_override("d3d11=n,b"); diff --git a/src/launch/fixups/seeds/72850.rhai b/src/launch/fixups/seeds/72850.rhai new file mode 100644 index 0000000..4cabc9b --- /dev/null +++ b/src/launch/fixups/seeds/72850.rhai @@ -0,0 +1,3 @@ +ctx.log("Skyrim Fixup"); +ctx.set_env("WINE_LARGE_ADDRESS_AWARE", "1"); +ctx.add_dll_override("d3d9=n"); diff --git a/src/launch/mod.rs b/src/launch/mod.rs index 1c0d8f7..3b17efc 100644 --- a/src/launch/mod.rs +++ b/src/launch/mod.rs @@ -1,4 +1,5 @@ pub mod pipeline; +pub mod fixups; pub mod stages; pub mod validators; pub mod dll_provider_resolver; diff --git a/src/launch/pipeline.rs b/src/launch/pipeline.rs index 8f098e5..9afcc1d 100644 --- a/src/launch/pipeline.rs +++ b/src/launch/pipeline.rs @@ -94,6 +94,7 @@ pub struct PipelineContext { pub warnings: Vec, pub graphics_stack: GraphicsStackInfo, pub dll_resolutions: Vec, + pub fixup_result: Option, pub verification: crate::infra::logging::LaunchVerification, } @@ -118,6 +119,7 @@ impl PipelineContext { warnings: Vec::new(), graphics_stack: GraphicsStackInfo::default(), dll_resolutions: Vec::new(), + fixup_result: None, verification: crate::infra::logging::LaunchVerification::default(), } } @@ -477,6 +479,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)); @@ -1291,6 +1294,10 @@ impl LaunchPipeline { } metadata.insert("nvapi_requested".to_string(), ctx.graphics_stack.requested_nvapi.to_string()); metadata.insert("nvapi_exposed".to_string(), ctx.graphics_stack.effective_nvapi.to_string()); + metadata.insert("protonfixes_routed".to_string(), ctx.verification.protonfixes_routed.to_string()); + if let Some(ref fixup) = ctx.verification.rhai_fixup_applied { + metadata.insert("rhai_fixup_applied".to_string(), fixup.clone()); + } if let Some(ref detailed) = ctx.verification.detailed_status { metadata.insert("verification_detailed".to_string(), detailed.clone()); } 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..22ed964 100644 --- a/src/launch/stages/mod.rs +++ b/src/launch/stages/mod.rs @@ -2,6 +2,7 @@ pub mod resolve_game; pub mod resolve_profile; pub mod resolve_components; pub mod resolve_dll_providers; +pub mod resolve_game_fixups; pub mod prepare_prefix; pub mod build_environment; pub mod build_command; 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..cdd0f2a --- /dev/null +++ b/src/launch/stages/resolve_game_fixups.rs @@ -0,0 +1,92 @@ +use crate::launch::pipeline::{PipelineStage, PipelineContext, LaunchError, LaunchErrorKind}; +use async_trait::async_trait; +use crate::utils::classify_runner; + +pub struct ResolveGameFixupsStage; + +#[async_trait] +impl PipelineStage for ResolveGameFixupsStage { + fn name(&self) -> &str { "ResolveGameFixups" } + + async fn execute(&self, ctx: &mut PipelineContext) -> Result<(), LaunchError> { + let library_root = std::path::PathBuf::from( + ctx.launcher_config.as_ref() + .map(|c| c.steam_library_path.clone()) + .unwrap_or_default() + ); + + let proton = if let Some(forced) = ctx.launcher_config.as_ref() + .and_then(|c| c.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_else(|| ctx.launcher_config.as_ref().map(|c| c.proton_version.as_str()).unwrap_or("Proton - Experimental")) + }; + + let active_runner_path = crate::utils::resolve_runner(proton, &library_root); + let kind = classify_runner(&active_runner_path); + + match kind { + crate::utils::RunnerKind::Proton { has_protonfixes, .. } if has_protonfixes => { + tracing::info!("Proton runner with bundled fixes detected. Skipping Rhai fixups."); + ctx.verification.protonfixes_routed = true; + ctx.fixup_result = None; + } + crate::utils::RunnerKind::PlainWine { .. } | crate::utils::RunnerKind::Proton { .. } => { + tracing::info!("Plain Wine or Proton without bundled fixes detected. Running Rhai fixups."); + ctx.verification.protonfixes_routed = false; + + let app_name = ctx.app.as_ref().map(|a| a.name.clone()).unwrap_or_default(); + let install_dir = ctx.resolved_install_dir.clone().unwrap_or_default(); + + let config = ctx.launcher_config.as_ref().ok_or_else(|| { + LaunchError::new(LaunchErrorKind::Validation, "Launcher config missing in ResolveGameFixupsStage") + })?; + + let wineprefix = crate::utils::steam_wineprefix_for_game( + config, + ctx.app_id, + &ctx.user_config.as_ref().map(|c| { + let mut store = std::collections::HashMap::new(); + store.insert(ctx.app_id, c.clone()); + store + }).unwrap_or_default().into() + ); + let arch = format!("{:?}", ctx.target_architecture).to_lowercase(); + + match crate::launch::fixups::run_fixup_script( + ctx.app_id, + app_name, + install_dir, + wineprefix, + arch, + ) { + Ok(Some(res)) => { + tracing::info!("Rhai fixup applied for AppID {}", ctx.app_id); + ctx.verification.rhai_fixup_applied = Some(ctx.app_id.to_string()); + ctx.fixup_result = Some(res); + } + Ok(None) => { + tracing::info!("No Rhai fixup found or applied for AppID {}", ctx.app_id); + ctx.fixup_result = None; + } + Err(e) => { + tracing::error!("Error running Rhai fixup for AppID {}: {}", ctx.app_id, e); + ctx.fixup_result = None; + } + } + } + crate::utils::RunnerKind::Unknown => { + return Err(LaunchError::new( + LaunchErrorKind::Runner, + format!("Failed to classify runner at {}", active_runner_path.display()) + )); + } + } + + Ok(()) + } +} diff --git a/src/ui.rs b/src/ui.rs index db3423b..e81c825 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2609,6 +2609,13 @@ impl eframe::App for SteamLauncher { ui.add_space(8.0); ui.label("Steam Runtime Runner:"); + if !self.launcher_config.steam_runtime_runner.as_os_str().is_empty() { + let kind = crate::utils::classify_runner(&self.launcher_config.steam_runtime_runner); + if matches!(kind, crate::utils::RunnerKind::Unknown) { + ui.colored_label(egui::Color32::YELLOW, "⚠️ Selected runner cannot be classified. Launch may fail."); + } + } + ui.horizontal(|ui| { use crate::models::RunnerSource; ui.radio_value(&mut self.launcher_config.steam_runtime_runner_source, RunnerSource::Official, "Official Proton (Valve)"); diff --git a/src/utils.rs b/src/utils.rs index 0074322..ceaf096 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -246,6 +246,96 @@ pub fn derive_runner_root(binary_path: &Path) -> PathBuf { parent.to_path_buf() } +#[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 { + /// Path to the `proton` script itself (for `{proton} run` invocation). + proton_script: PathBuf, + /// Path to the wine64 binary bundled inside this Proton tree, for bare-wine invocation + /// (used when this Proton tree is selected for the background Steam role). + bundled_wine64: Option, + /// True if files/protonfixes or dist/protonfixes exists (the actual fixes database). + 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 { + if !runner_path.exists() { + return RunnerKind::Unknown; + } + + // 1. Check if it's literally a wine binary + if runner_path.is_file() { + if let Some(name) = runner_path.file_name().and_then(|n| n.to_str()) { + if name == "wine" || name == "wine64" { + return RunnerKind::PlainWine { + wine64: runner_path.to_path_buf(), + wine32: None, // We don't know the 32-bit counterpart easily from just a file path + }; + } + } + } + + let root = if runner_path.is_dir() { + runner_path.to_path_buf() + } else { + derive_runner_root(runner_path) + }; + + let proton_script = root.join("proton"); + if proton_script.is_file() { + // It has a proton script, check for protonfixes + let fix_candidates = ["protonfixes", "files/protonfixes", "dist/protonfixes"]; + let has_protonfixes = fix_candidates.iter().any(|c| root.join(c).is_dir()); + + // Find bundled wine64 + let wine_candidates = ["files/bin/wine64", "dist/bin/wine64", "bin/wine64"]; + let bundled_wine64 = wine_candidates + .iter() + .map(|c| root.join(c)) + .find(|p| p.is_file()); + + return RunnerKind::Proton { + proton_script, + bundled_wine64, + has_protonfixes, + }; + } + + // Check for PlainWine (directory layout) + let wine64_path = root.join("bin/wine64"); + let wine32_path = root.join("bin/wine"); + + if wine64_path.is_file() || wine32_path.is_file() { + let final_wine64 = if wine64_path.is_file() { + wine64_path + } else { + wine32_path.clone() + }; + + let final_wine32 = if wine32_path.is_file() && wine32_path != final_wine64 { + Some(wine32_path) + } else { + None + }; + + return RunnerKind::PlainWine { + wine64: final_wine64, + wine32: final_wine32, + }; + } + + RunnerKind::Unknown +} + pub fn detect_runner_components( runner_path: &Path, wineprefix: Option<&Path>, @@ -1488,3 +1578,77 @@ pub fn steam_wineprefix_for_game( resolve_master_wineprefix() } } + +#[cfg(test)] +mod classify_tests { + use super::*; + use tempfile::tempdir; + use std::fs; + + #[test] + fn test_classify_proton() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + fs::write(root.join("proton"), "").unwrap(); + fs::create_dir(root.join("protonfixes")).unwrap(); + fs::create_dir_all(root.join("files/bin")).unwrap(); + fs::write(root.join("files/bin/wine64"), "").unwrap(); + + let kind = classify_runner(root); + match kind { + RunnerKind::Proton { has_protonfixes, bundled_wine64, .. } => { + assert!(has_protonfixes); + assert!(bundled_wine64.is_some()); + assert!(bundled_wine64.unwrap().to_string_lossy().contains("files/bin/wine64")); + } + _ => panic!("Expected Proton, got {:?}", kind), + } + } + + #[test] + fn test_classify_plain_wine_dir() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + fs::create_dir_all(root.join("bin")).unwrap(); + fs::write(root.join("bin/wine64"), "").unwrap(); + fs::write(root.join("bin/wine"), "").unwrap(); + + let kind = classify_runner(root); + match kind { + RunnerKind::PlainWine { wine64, wine32 } => { + assert!(wine64.to_string_lossy().contains("bin/wine64")); + assert!(wine32.is_some()); + assert!(wine32.unwrap().to_string_lossy().contains("bin/wine")); + } + _ => panic!("Expected PlainWine, got {:?}", kind), + } + } + + #[test] + fn test_classify_plain_wine_binary() { + let dir = tempdir().unwrap(); + let wine64 = dir.path().join("wine64"); + fs::write(&wine64, "").unwrap(); + + let kind = classify_runner(&wine64); + match kind { + RunnerKind::PlainWine { wine64: p, .. } => { + assert_eq!(p, wine64); + } + _ => panic!("Expected PlainWine, got {:?}", kind), + } + } + + #[test] + fn test_classify_unknown() { + let dir = tempdir().unwrap(); + let root = dir.path(); + + fs::write(root.join("something_else"), "").unwrap(); + + let kind = classify_runner(root); + assert_eq!(kind, RunnerKind::Unknown); + } +}