| 1 | //! Proton version detection and management |
| 2 | |
| 3 | use crate::config::WandaConfig; |
| 4 | use crate::error::{Result, WandaError}; |
| 5 | use crate::steam::SteamInstallation; |
| 6 | use std::cmp::Ordering; |
| 7 | use std::fmt; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | use tracing::{debug, info, warn}; |
| 10 | |
| 11 | /// Proton compatibility level with WeMod |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 13 | pub enum ProtonCompatibility { |
| 14 | /// Known to work well with WeMod (GE-Proton 8+) |
| 15 | Recommended, |
| 16 | /// Should work with WeMod |
| 17 | Supported, |
| 18 | /// May work but not tested |
| 19 | Experimental, |
| 20 | /// Known to have issues |
| 21 | Unsupported, |
| 22 | } |
| 23 | |
| 24 | impl fmt::Display for ProtonCompatibility { |
| 25 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 26 | match self { |
| 27 | ProtonCompatibility::Recommended => write!(f, "Recommended"), |
| 28 | ProtonCompatibility::Supported => write!(f, "Supported"), |
| 29 | ProtonCompatibility::Experimental => write!(f, "Experimental"), |
| 30 | ProtonCompatibility::Unsupported => write!(f, "Unsupported"), |
| 31 | } |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// A detected Proton installation |
| 36 | #[derive(Debug, Clone)] |
| 37 | pub struct ProtonVersion { |
| 38 | /// Display name (e.g., "GE-Proton9-5", "Proton 8.0") |
| 39 | pub name: String, |
| 40 | /// Path to the Proton installation |
| 41 | pub path: PathBuf, |
| 42 | /// Parsed version numbers (major, minor, patch) |
| 43 | pub version: (u32, u32, u32), |
| 44 | /// Whether this is a GE-Proton build |
| 45 | pub is_ge: bool, |
| 46 | /// Whether this is Proton Experimental |
| 47 | pub is_experimental: bool, |
| 48 | /// Compatibility level with WeMod |
| 49 | pub compatibility: ProtonCompatibility, |
| 50 | } |
| 51 | |
| 52 | impl ProtonVersion { |
| 53 | /// Get the path to the proton executable |
| 54 | pub fn proton_exe(&self) -> PathBuf { |
| 55 | self.path.join("proton") |
| 56 | } |
| 57 | |
| 58 | /// Get the path to the Wine binary |
| 59 | pub fn wine_exe(&self) -> PathBuf { |
| 60 | self.path.join("files/bin/wine64") |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | impl PartialOrd for ProtonVersion { |
| 65 | fn partial_cmp(&self, other: &Self) -> Option<Ordering> { |
| 66 | Some(self.cmp(other)) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | impl Ord for ProtonVersion { |
| 71 | fn cmp(&self, other: &Self) -> Ordering { |
| 72 | // Prefer GE-Proton over standard |
| 73 | match (self.is_ge, other.is_ge) { |
| 74 | (true, false) => return Ordering::Greater, |
| 75 | (false, true) => return Ordering::Less, |
| 76 | _ => {} |
| 77 | } |
| 78 | // Then compare by version |
| 79 | self.version.cmp(&other.version) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | impl PartialEq for ProtonVersion { |
| 84 | fn eq(&self, other: &Self) -> bool { |
| 85 | self.name == other.name && self.path == other.path |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | impl Eq for ProtonVersion {} |
| 90 | |
| 91 | /// Manages Proton version discovery and selection |
| 92 | pub struct ProtonManager { |
| 93 | /// All discovered Proton versions |
| 94 | pub versions: Vec<ProtonVersion>, |
| 95 | } |
| 96 | |
| 97 | impl ProtonManager { |
| 98 | /// Discover all Proton versions on the system |
| 99 | pub fn discover(steam: &SteamInstallation, config: &WandaConfig) -> Result<Self> { |
| 100 | let mut versions = Vec::new(); |
| 101 | |
| 102 | // Search in Steam's common folder (official Proton) |
| 103 | let common_dir = steam.root_path.join("steamapps/common"); |
| 104 | Self::scan_directory(&common_dir, &mut versions); |
| 105 | |
| 106 | // Search in compatibility tools directory (GE-Proton, custom builds) |
| 107 | let compat_tools_dir = steam.root_path.join("compatibilitytools.d"); |
| 108 | Self::scan_directory(&compat_tools_dir, &mut versions); |
| 109 | |
| 110 | // System-wide compatibility tools |
| 111 | let system_compat = PathBuf::from("/usr/share/steam/compatibilitytools.d"); |
| 112 | if system_compat.exists() { |
| 113 | Self::scan_directory(&system_compat, &mut versions); |
| 114 | } |
| 115 | |
| 116 | // User-configured additional paths |
| 117 | for path in &config.proton.search_paths { |
| 118 | if path.exists() { |
| 119 | Self::scan_directory(path, &mut versions); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | // Sort by preference (GE-Proton first, then by version descending) |
| 124 | versions.sort_by(|a, b| b.cmp(a)); |
| 125 | |
| 126 | info!("Discovered {} Proton versions", versions.len()); |
| 127 | for v in &versions { |
| 128 | debug!( |
| 129 | " {} ({:?}) at {}", |
| 130 | v.name, |
| 131 | v.compatibility, |
| 132 | v.path.display() |
| 133 | ); |
| 134 | } |
| 135 | |
| 136 | Ok(Self { versions }) |
| 137 | } |
| 138 | |
| 139 | /// Scan a directory for Proton installations |
| 140 | fn scan_directory(dir: &Path, versions: &mut Vec<ProtonVersion>) { |
| 141 | let entries = match std::fs::read_dir(dir) { |
| 142 | Ok(e) => e, |
| 143 | Err(_) => return, |
| 144 | }; |
| 145 | |
| 146 | for entry in entries.flatten() { |
| 147 | let path = entry.path(); |
| 148 | if path.is_dir() { |
| 149 | if let Some(version) = Self::detect_proton(&path) { |
| 150 | versions.push(version); |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// Detect if a directory contains a Proton installation |
| 157 | fn detect_proton(path: &Path) -> Option<ProtonVersion> { |
| 158 | // Proton directories should have a 'proton' script |
| 159 | let proton_script = path.join("proton"); |
| 160 | if !proton_script.exists() { |
| 161 | return None; |
| 162 | } |
| 163 | |
| 164 | let name = path.file_name()?.to_str()?.to_string(); |
| 165 | |
| 166 | // Parse version from name |
| 167 | let (version, is_ge, is_experimental) = Self::parse_version_from_name(&name); |
| 168 | let compatibility = Self::determine_compatibility(&name, version, is_ge, is_experimental); |
| 169 | |
| 170 | Some(ProtonVersion { |
| 171 | name, |
| 172 | path: path.to_path_buf(), |
| 173 | version, |
| 174 | is_ge, |
| 175 | is_experimental, |
| 176 | compatibility, |
| 177 | }) |
| 178 | } |
| 179 | |
| 180 | /// Parse version numbers from Proton directory name |
| 181 | fn parse_version_from_name(name: &str) -> ((u32, u32, u32), bool, bool) { |
| 182 | let is_ge = name.contains("GE-Proton") || name.contains("Proton-GE"); |
| 183 | let is_experimental = name.contains("Experimental"); |
| 184 | |
| 185 | // Try to extract version numbers |
| 186 | // GE-Proton format: "GE-Proton9-5" -> (9, 5, 0) |
| 187 | // Standard format: "Proton 8.0-5" -> (8, 0, 5) |
| 188 | // Experimental: "Proton - Experimental" -> (999, 0, 0) to sort high |
| 189 | |
| 190 | if is_experimental { |
| 191 | return ((999, 0, 0), is_ge, is_experimental); |
| 192 | } |
| 193 | |
| 194 | let version = Self::extract_version_numbers(name); |
| 195 | (version, is_ge, is_experimental) |
| 196 | } |
| 197 | |
| 198 | /// Extract version numbers from a string |
| 199 | fn extract_version_numbers(s: &str) -> (u32, u32, u32) { |
| 200 | let numbers: Vec<u32> = s |
| 201 | .chars() |
| 202 | .filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-') |
| 203 | .collect::<String>() |
| 204 | .split(|c| c == '.' || c == '-') |
| 205 | .filter_map(|n| n.parse().ok()) |
| 206 | .collect(); |
| 207 | |
| 208 | match numbers.as_slice() { |
| 209 | [major, minor, patch, ..] => (*major, *minor, *patch), |
| 210 | [major, minor] => (*major, *minor, 0), |
| 211 | [major] => (*major, 0, 0), |
| 212 | [] => (0, 0, 0), |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | /// Determine compatibility level based on version info |
| 217 | fn determine_compatibility( |
| 218 | name: &str, |
| 219 | version: (u32, u32, u32), |
| 220 | is_ge: bool, |
| 221 | is_experimental: bool, |
| 222 | ) -> ProtonCompatibility { |
| 223 | // Proton Experimental is recommended for WeMod |
| 224 | // GE-Proton 10.x has wow64 mode issues that cause WeMod to crash |
| 225 | if is_experimental { |
| 226 | return ProtonCompatibility::Recommended; |
| 227 | } |
| 228 | |
| 229 | // GE-Proton 9.x and below should work (before wow64 mode) |
| 230 | if is_ge && version.0 >= 8 && version.0 <= 9 { |
| 231 | return ProtonCompatibility::Supported; |
| 232 | } |
| 233 | |
| 234 | // GE-Proton 10.x has known wow64 issues with WeMod |
| 235 | if is_ge && version.0 >= 10 { |
| 236 | return ProtonCompatibility::Experimental; |
| 237 | } |
| 238 | |
| 239 | // GE-Proton 7.x might work |
| 240 | if is_ge && version.0 >= 7 { |
| 241 | return ProtonCompatibility::Supported; |
| 242 | } |
| 243 | |
| 244 | // Standard Proton 8+ should work |
| 245 | if version.0 >= 8 { |
| 246 | return ProtonCompatibility::Supported; |
| 247 | } |
| 248 | |
| 249 | // Wine-GE has known issues |
| 250 | if name.contains("Wine-GE") { |
| 251 | return ProtonCompatibility::Unsupported; |
| 252 | } |
| 253 | |
| 254 | // Older versions might have issues |
| 255 | if version.0 < 7 { |
| 256 | return ProtonCompatibility::Unsupported; |
| 257 | } |
| 258 | |
| 259 | ProtonCompatibility::Experimental |
| 260 | } |
| 261 | |
| 262 | /// Get the recommended Proton version for WeMod |
| 263 | pub fn get_recommended(&self) -> Option<&ProtonVersion> { |
| 264 | // First try to find a Recommended version |
| 265 | if let Some(v) = self |
| 266 | .versions |
| 267 | .iter() |
| 268 | .find(|v| v.compatibility == ProtonCompatibility::Recommended) |
| 269 | { |
| 270 | return Some(v); |
| 271 | } |
| 272 | |
| 273 | // Fall back to Supported |
| 274 | if let Some(v) = self |
| 275 | .versions |
| 276 | .iter() |
| 277 | .find(|v| v.compatibility == ProtonCompatibility::Supported) |
| 278 | { |
| 279 | return Some(v); |
| 280 | } |
| 281 | |
| 282 | // Fall back to Experimental |
| 283 | self.versions |
| 284 | .iter() |
| 285 | .find(|v| v.compatibility == ProtonCompatibility::Experimental) |
| 286 | } |
| 287 | |
| 288 | /// Find a Proton version by name |
| 289 | pub fn find_by_name(&self, name: &str) -> Option<&ProtonVersion> { |
| 290 | let name_lower = name.to_lowercase(); |
| 291 | self.versions |
| 292 | .iter() |
| 293 | .find(|v| v.name.to_lowercase().contains(&name_lower)) |
| 294 | } |
| 295 | |
| 296 | /// Get a version by preference: user-specified, then recommended |
| 297 | pub fn get_preferred(&self, config: &WandaConfig) -> Result<&ProtonVersion> { |
| 298 | // Check user preference first |
| 299 | if let Some(ref preferred) = config.proton.preferred_version { |
| 300 | if let Some(v) = self.find_by_name(preferred) { |
| 301 | match v.compatibility { |
| 302 | ProtonCompatibility::Unsupported => { |
| 303 | warn!( |
| 304 | "Preferred Proton version '{}' is {} — it has known compatibility issues with WeMod", |
| 305 | v.name, v.compatibility |
| 306 | ); |
| 307 | if let Some(rec) = self.get_recommended() { |
| 308 | warn!( |
| 309 | "Consider switching to '{}' ({}): wanda init --proton '{}'", |
| 310 | rec.name, rec.compatibility, rec.name |
| 311 | ); |
| 312 | } |
| 313 | } |
| 314 | ProtonCompatibility::Experimental => { |
| 315 | warn!( |
| 316 | "Preferred Proton version '{}' is {} — GE-Proton 10.x wow64 mode can break WeMod's renderer", |
| 317 | v.name, v.compatibility |
| 318 | ); |
| 319 | if let Some(rec) = self.get_recommended() { |
| 320 | warn!( |
| 321 | "Consider switching to '{}' ({}): wanda init --proton '{}'", |
| 322 | rec.name, rec.compatibility, rec.name |
| 323 | ); |
| 324 | } |
| 325 | } |
| 326 | _ => {} |
| 327 | } |
| 328 | return Ok(v); |
| 329 | } |
| 330 | warn!( |
| 331 | "Preferred Proton version '{}' not found, using recommended", |
| 332 | preferred |
| 333 | ); |
| 334 | } |
| 335 | |
| 336 | self.get_recommended().ok_or(WandaError::ProtonNotFound) |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | #[cfg(test)] |
| 341 | mod tests { |
| 342 | use super::*; |
| 343 | |
| 344 | #[test] |
| 345 | fn test_version_parsing() { |
| 346 | let (v, is_ge, _) = ProtonManager::parse_version_from_name("GE-Proton9-5"); |
| 347 | assert_eq!(v, (9, 5, 0)); |
| 348 | assert!(is_ge); |
| 349 | |
| 350 | let (v, is_ge, _) = ProtonManager::parse_version_from_name("Proton 8.0-5"); |
| 351 | assert_eq!(v, (8, 0, 5)); |
| 352 | assert!(!is_ge); |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn test_compatibility() { |
| 357 | // Proton Experimental is now recommended (GE-Proton 10.x has wow64 issues) |
| 358 | let compat = |
| 359 | ProtonManager::determine_compatibility("Proton - Experimental", (0, 0, 0), false, true); |
| 360 | assert_eq!(compat, ProtonCompatibility::Recommended); |
| 361 | |
| 362 | // GE-Proton 9.x is supported (before wow64 issues) |
| 363 | let compat = |
| 364 | ProtonManager::determine_compatibility("GE-Proton9-5", (9, 5, 0), true, false); |
| 365 | assert_eq!(compat, ProtonCompatibility::Supported); |
| 366 | |
| 367 | // GE-Proton 10.x is experimental (wow64 issues) |
| 368 | let compat = |
| 369 | ProtonManager::determine_compatibility("GE-Proton10-28", (10, 28, 0), true, false); |
| 370 | assert_eq!(compat, ProtonCompatibility::Experimental); |
| 371 | |
| 372 | let compat = |
| 373 | ProtonManager::determine_compatibility("Proton 6.0", (6, 0, 0), false, false); |
| 374 | assert_eq!(compat, ProtonCompatibility::Unsupported); |
| 375 | } |
| 376 | } |
| 377 |