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